feat: coinjoin promotion / demotion - #7052
Conversation
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
✅ No Merge Conflicts DetectedThis PR currently has no conflicts with other open PRs. |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds Post-V24 CoinJoin promotion and demotion between adjacent denominations. The client selects and locks specialized inputs, joins target-denomination queues, and prepares rebalance entries. Chain-aware validation, DSTX handling, server pool rules, wallet helpers, protocol negotiation, tests, and release notes support the new entry shapes. Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Client as CCoinJoinClientManager
participant Session as CCoinJoinClientSession
participant Wallet as CWallet
participant Server as CCoinJoinServer
participant Network as ValidateDSTX
Client->>Client: DoAutomaticDenominating()
Client->>Session: JoinExistingQueue() or StartNewQueue()
Session->>Wallet: Select and lock rebalance inputs
Session->>Server: SendDenominate()
Server->>Server: Validate entry and update pool
Network->>Server: Validate DSTX structure with chain context
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 0
🧹 Nitpick comments (4)
src/net_processing.cpp (1)
3516-3526: Tip-aware DSTX structure validation looks correct, minor clarity tweak possibleUsing
chainman.ActiveChain().Tip()undercs_mainand passingpindexintodstx.IsValidStructure(pindex)is the right direction for fork‑/deployment‑aware DSTX checks and matches existing patterns in this file that rely on a non‑null active tip.Two small nits you may consider for clarity (not blockers):
- Use a separate local (e.g.
const CBlockIndex* pindex_for_validation = pindex;) before the laterforloops that walkpindexback 24 blocks, so it’s obvious that the structure check uses the current tip and is independent from the masternode lookup iteration.- Optionally add a brief comment above the
LOCK(cs_main)explaining that we intentionally snapshot the current tip for tip‑dependent CoinJoin structure rules.Otherwise this change looks consistent with the new CoinJoin promotion/demotion and V24‑aware validation flow.
src/wallet/coinjoin.cpp (1)
443-471: Consider early termination placement for clarity.The early termination check at line 453 occurs after the lock is acquired but before any significant work. While functionally correct, this could be slightly more efficient if moved before the wallet lookup, though the impact is negligible.
The function correctly:
- Guards on enabled state and valid denomination
- Filters for confirmed depth (
< 1)- Requires fully mixed status for promotion candidates
Minor readability improvement - the early break is fine, but consider:
LOCK(cs_wallet); for (const auto& outpoint : setWalletUTXO) { - if (static_cast<int>(vecRet.size()) >= nCount) break; + if (vecRet.size() >= static_cast<size_t>(nCount)) break;This avoids a sign conversion and is slightly more idiomatic.
src/coinjoin/coinjoin.cpp (1)
289-313: Minor: Consider extracting checkTxOut to reduce closure complexity.The
checkTxOutlambda captures multiple variables and handles denomination validation, script checks, and duplicate detection. While functional, extracting this as a private member function could improve testability.The logic is correct:
- Validates denomination against expected
- Ensures P2PKH script type
- Prevents duplicate scriptPubKeys (privacy requirement)
src/coinjoin/client.cpp (1)
1375-1494: Consider extracting common masternode selection logic.The masternode selection loop (lines 1435-1491) is nearly identical to the standard
StartNewQueue(lines 1316-1372). The differences are:
- Input validation at the start
- Denomination is fixed rather than selected from
setAmounts- Promotion/demotion state is stored
This duplication could lead to maintenance issues if the masternode selection logic needs updates.
Consider extracting the common masternode selection and connection logic into a private helper method that both
StartNewQueueoverloads can call, passing in the denomination and a callback for post-connection setup.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (10)
src/coinjoin/client.cpp(12 hunks)src/coinjoin/client.h(3 hunks)src/coinjoin/coinjoin.cpp(6 hunks)src/coinjoin/coinjoin.h(2 hunks)src/coinjoin/common.h(1 hunks)src/coinjoin/server.cpp(3 hunks)src/net_processing.cpp(1 hunks)src/test/coinjoin_inouts_tests.cpp(4 hunks)src/wallet/coinjoin.cpp(3 hunks)src/wallet/wallet.h(3 hunks)
🧰 Additional context used
📓 Path-based instructions (6)
src/**/*.{cpp,h,hpp,cc}
📄 CodeRabbit inference engine (CLAUDE.md)
Dash Core implementation must be written in C++20, requiring at least Clang 16 or GCC 11.1
Files:
src/coinjoin/server.cppsrc/net_processing.cppsrc/coinjoin/coinjoin.hsrc/test/coinjoin_inouts_tests.cppsrc/coinjoin/common.hsrc/wallet/wallet.hsrc/coinjoin/coinjoin.cppsrc/coinjoin/client.hsrc/coinjoin/client.cppsrc/wallet/coinjoin.cpp
src/{masternode,evo,llmq,governance,coinjoin}/**/*.{cpp,h}
📄 CodeRabbit inference engine (CLAUDE.md)
Use Dash-specific database implementations: CFlatDB for persistent storage (MasternodeMetaStore, GovernanceStore, SporkStore, NetFulfilledRequestStore) and CDBWrapper extensions for Evolution/DKG/InstantSend/Quorum/RecoveredSigs data
Files:
src/coinjoin/server.cppsrc/coinjoin/coinjoin.hsrc/coinjoin/common.hsrc/coinjoin/coinjoin.cppsrc/coinjoin/client.hsrc/coinjoin/client.cpp
src/coinjoin/**/*.{cpp,h}
📄 CodeRabbit inference engine (CLAUDE.md)
CoinJoin implementation must use masternode-coordinated mixing sessions with uniform denomination outputs
Files:
src/coinjoin/server.cppsrc/coinjoin/coinjoin.hsrc/coinjoin/common.hsrc/coinjoin/coinjoin.cppsrc/coinjoin/client.hsrc/coinjoin/client.cpp
src/{masternode,llmq,evo,coinjoin,governance}/**/*.{cpp,h}
📄 CodeRabbit inference engine (CLAUDE.md)
Use unordered_lru_cache for efficient caching with LRU eviction in Dash-specific data structures
Files:
src/coinjoin/server.cppsrc/coinjoin/coinjoin.hsrc/coinjoin/common.hsrc/coinjoin/coinjoin.cppsrc/coinjoin/client.hsrc/coinjoin/client.cpp
src/{test,wallet/test}/**/*.{cpp,h}
📄 CodeRabbit inference engine (CLAUDE.md)
Unit tests in src/test/ and src/wallet/test/ must use Boost::Test framework
Files:
src/test/coinjoin_inouts_tests.cpp
src/wallet/**/*.{cpp,h}
📄 CodeRabbit inference engine (CLAUDE.md)
Wallet implementation must use Berkeley DB and SQLite
Files:
src/wallet/wallet.hsrc/wallet/coinjoin.cpp
🧠 Learnings (21)
📓 Common learnings
Learnt from: kwvg
Repo: dashpay/dash PR: 6543
File: src/wallet/receive.cpp:240-251
Timestamp: 2025-02-06T14:34:30.466Z
Learning: Pull request #6543 is focused on move-only changes and refactoring, specifically backporting from Bitcoin. Behavior changes should be proposed in separate PRs.
Learnt from: CR
Repo: dashpay/dash PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-24T16:41:22.457Z
Learning: Applies to src/coinjoin/**/*.{cpp,h} : CoinJoin implementation must use masternode-coordinated mixing sessions with uniform denomination outputs
📚 Learning: 2025-11-24T16:41:22.457Z
Learnt from: CR
Repo: dashpay/dash PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-24T16:41:22.457Z
Learning: Applies to src/coinjoin/**/*.{cpp,h} : CoinJoin implementation must use masternode-coordinated mixing sessions with uniform denomination outputs
Applied to files:
src/coinjoin/server.cppsrc/net_processing.cppsrc/coinjoin/coinjoin.hsrc/test/coinjoin_inouts_tests.cppsrc/coinjoin/common.hsrc/wallet/wallet.hsrc/coinjoin/coinjoin.cppsrc/coinjoin/client.hsrc/coinjoin/client.cppsrc/wallet/coinjoin.cpp
📚 Learning: 2025-06-06T11:53:09.094Z
Learnt from: kwvg
Repo: dashpay/dash PR: 6665
File: src/evo/providertx.h:82-82
Timestamp: 2025-06-06T11:53:09.094Z
Learning: In ProTx serialization code (SERIALIZE_METHODS), version checks should use hardcoded maximum flags (/*is_basic_scheme_active=*/true, /*is_extended_addr=*/true) rather than deployment-based flags. This is because serialization code should be able to deserialize any structurally valid ProTx up to the maximum version the code knows how to handle, regardless of current consensus validity. Validation code, not serialization code, is responsible for checking whether a ProTx version is consensus-valid based on deployment status.
Applied to files:
src/coinjoin/server.cppsrc/net_processing.cppsrc/coinjoin/coinjoin.cpp
📚 Learning: 2025-11-24T16:41:22.457Z
Learnt from: CR
Repo: dashpay/dash PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-24T16:41:22.457Z
Learning: Applies to src/{masternode,llmq}/**/*.{cpp,h} : BLS integration must be used for cryptographic foundation of advanced masternode features
Applied to files:
src/coinjoin/server.cppsrc/wallet/wallet.hsrc/coinjoin/client.cpp
📚 Learning: 2025-11-24T16:41:22.457Z
Learnt from: CR
Repo: dashpay/dash PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-24T16:41:22.457Z
Learning: Applies to src/node/chainstate.{cpp,h} : Chainstate initialization must be separated into dedicated src/node/chainstate.* files
Applied to files:
src/coinjoin/server.cppsrc/coinjoin/coinjoin.cppsrc/coinjoin/client.cpp
📚 Learning: 2025-11-24T16:41:22.457Z
Learnt from: CR
Repo: dashpay/dash PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-24T16:41:22.457Z
Learning: Applies to src/{masternode,evo,llmq,governance,coinjoin}/**/*.{cpp,h} : Use Dash-specific database implementations: CFlatDB for persistent storage (MasternodeMetaStore, GovernanceStore, SporkStore, NetFulfilledRequestStore) and CDBWrapper extensions for Evolution/DKG/InstantSend/Quorum/RecoveredSigs data
Applied to files:
src/coinjoin/server.cppsrc/wallet/wallet.hsrc/coinjoin/client.cpp
📚 Learning: 2025-11-24T16:41:22.457Z
Learnt from: CR
Repo: dashpay/dash PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-24T16:41:22.457Z
Learning: Applies to src/{validation,txmempool}/**/*.{cpp,h} : Block validation and mempool handling must use extensions to Bitcoin Core mechanisms for special transaction validation and enhanced transaction relay
Applied to files:
src/net_processing.cppsrc/coinjoin/coinjoin.hsrc/test/coinjoin_inouts_tests.cppsrc/coinjoin/common.hsrc/wallet/wallet.hsrc/coinjoin/coinjoin.cppsrc/coinjoin/client.cppsrc/wallet/coinjoin.cpp
📚 Learning: 2025-11-24T16:41:22.457Z
Learnt from: CR
Repo: dashpay/dash PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-24T16:41:22.457Z
Learning: Applies to src/{validation,consensus,net_processing}/**/*.{cpp,h} : ValidationInterface callbacks must be used for event-driven architecture to coordinate subsystems during block/transaction processing
Applied to files:
src/net_processing.cppsrc/coinjoin/coinjoin.hsrc/wallet/wallet.hsrc/coinjoin/coinjoin.cppsrc/coinjoin/client.cpp
📚 Learning: 2025-08-19T14:57:31.801Z
Learnt from: knst
Repo: dashpay/dash PR: 6692
File: src/llmq/blockprocessor.cpp:217-224
Timestamp: 2025-08-19T14:57:31.801Z
Learning: In PR #6692, knst acknowledged a null pointer dereference issue in ProcessBlock() method where LookupBlockIndex may return nullptr but is passed to gsl::not_null, and created follow-up PR #6789 to address it, consistent with avoiding scope creep in performance-focused PRs.
Applied to files:
src/net_processing.cpp
📚 Learning: 2025-07-09T15:02:26.899Z
Learnt from: kwvg
Repo: dashpay/dash PR: 6729
File: src/evo/deterministicmns.cpp:1313-1316
Timestamp: 2025-07-09T15:02:26.899Z
Learning: In Dash's masternode transaction validation, `IsVersionChangeValid()` is only called by transaction types that update existing masternode entries (like `ProUpServTx`, `ProUpRegTx`, `ProUpRevTx`), not by `ProRegTx` which creates new entries. This means validation logic in `IsVersionChangeValid()` only applies to the subset of transaction types that actually call it, not all masternode transaction types.
Applied to files:
src/coinjoin/coinjoin.h
📚 Learning: 2025-05-05T12:45:44.781Z
Learnt from: knst
Repo: dashpay/dash PR: 6658
File: src/evo/creditpool.cpp:177-185
Timestamp: 2025-05-05T12:45:44.781Z
Learning: The GetAncestor() function in CBlockIndex safely handles negative heights by returning nullptr rather than asserting, making it safe to call with potentially negative values.
Applied to files:
src/coinjoin/coinjoin.h
📚 Learning: 2025-11-24T16:41:22.457Z
Learnt from: CR
Repo: dashpay/dash PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-24T16:41:22.457Z
Learning: Applies to src/{test,wallet/test}/**/*.{cpp,h} : Unit tests in src/test/ and src/wallet/test/ must use Boost::Test framework
Applied to files:
src/test/coinjoin_inouts_tests.cpp
📚 Learning: 2025-06-09T16:43:20.996Z
Learnt from: kwvg
Repo: dashpay/dash PR: 6718
File: test/functional/test_framework/test_framework.py:2102-2102
Timestamp: 2025-06-09T16:43:20.996Z
Learning: In the test framework consolidation PR (#6718), user kwvg prefers to limit functional changes to those directly related to MasternodeInfo, avoiding scope creep even for minor improvements like error handling consistency.
Applied to files:
src/test/coinjoin_inouts_tests.cpp
📚 Learning: 2025-08-08T07:01:47.332Z
Learnt from: knst
Repo: dashpay/dash PR: 6805
File: src/wallet/rpc/wallet.cpp:357-357
Timestamp: 2025-08-08T07:01:47.332Z
Learning: In src/wallet/rpc/wallet.cpp, the upgradetohd RPC now returns a UniValue string message (RPCResult::Type::STR) instead of a boolean, including guidance about mnemonic backup and null-character passphrase handling; functional tests have been updated to assert returned strings in several cases.
Applied to files:
src/test/coinjoin_inouts_tests.cppsrc/wallet/wallet.hsrc/coinjoin/coinjoin.cpp
📚 Learning: 2025-11-24T16:41:22.457Z
Learnt from: CR
Repo: dashpay/dash PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-24T16:41:22.457Z
Learning: Applies to src/wallet/**/*.{cpp,h} : Wallet implementation must use Berkeley DB and SQLite
Applied to files:
src/wallet/wallet.hsrc/coinjoin/client.cpp
📚 Learning: 2025-11-24T16:41:22.457Z
Learnt from: CR
Repo: dashpay/dash PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-24T16:41:22.457Z
Learning: Applies to src/evo/evodb/**/*.{cpp,h} : Evolution Database (CEvoDb) must handle masternode snapshots, quorum state, governance objects with efficient differential updates for masternode lists
Applied to files:
src/coinjoin/client.cpp
📚 Learning: 2025-11-25T10:53:37.523Z
Learnt from: knst
Repo: dashpay/dash PR: 7008
File: src/masternode/sync.h:17-18
Timestamp: 2025-11-25T10:53:37.523Z
Learning: The file src/masternode/sync.h containing `CMasternodeSync` is misnamed and misplaced—it has nothing to do with "masternode" functionality. It should eventually be renamed to `NodeSyncing` or `NodeSyncStatus` and moved to src/node/sync.h as a future refactoring.
Applied to files:
src/coinjoin/client.cpp
📚 Learning: 2025-11-24T16:41:22.457Z
Learnt from: CR
Repo: dashpay/dash PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-24T16:41:22.457Z
Learning: Applies to src/{masternode,evo}/**/*.{cpp,h} : Masternode lists must use immutable data structures (Immer library) for thread safety
Applied to files:
src/coinjoin/client.cpp
📚 Learning: 2025-11-24T16:41:22.457Z
Learnt from: CR
Repo: dashpay/dash PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-24T16:41:22.457Z
Learning: Applies to src/evo/**/*.{cpp,h} : Special transactions use payload serialization routines defined in src/evo/specialtx.h and must include appropriate special transaction types (ProRegTx, ProUpServTx, ProUpRegTx, ProUpRevTx)
Applied to files:
src/coinjoin/client.cpp
📚 Learning: 2025-02-14T15:19:17.218Z
Learnt from: kwvg
Repo: dashpay/dash PR: 6529
File: src/wallet/rpcwallet.cpp:3002-3003
Timestamp: 2025-02-14T15:19:17.218Z
Learning: The `GetWallet()` function calls in `src/wallet/rpcwallet.cpp` are properly validated with null checks that throw appropriate RPC errors, making additional validation unnecessary.
Applied to files:
src/coinjoin/client.cpp
📚 Learning: 2025-09-02T07:34:28.226Z
Learnt from: knst
Repo: dashpay/dash PR: 6834
File: test/functional/wallet_mnemonicbits.py:50-51
Timestamp: 2025-09-02T07:34:28.226Z
Learning: CJ (CoinJoin) descriptors with derivation path "9'/1" are intentionally inactive in descriptor wallets, while regular internal/external descriptors with different derivation paths remain active.
Applied to files:
src/wallet/coinjoin.cpp
🧬 Code graph analysis (6)
src/coinjoin/coinjoin.h (1)
src/coinjoin/coinjoin.cpp (6)
IsValidStructure(93-136)IsValidStructure(93-93)ValidatePromotionEntry(584-633)ValidatePromotionEntry(584-585)ValidateDemotionEntry(635-675)ValidateDemotionEntry(635-636)
src/wallet/wallet.h (1)
src/wallet/coinjoin.cpp (8)
SelectTxDSInsByDenomination(48-51)SelectTxDSInsByDenomination(48-48)SelectTxDSInsByDenomination(53-93)SelectTxDSInsByDenomination(53-53)CountCoinsByDenomination(414-441)CountCoinsByDenomination(414-414)SelectFullyMixedForPromotion(443-471)SelectFullyMixedForPromotion(443-443)
src/coinjoin/coinjoin.cpp (1)
src/coinjoin/common.h (3)
IsDenominatedAmount(101-101)GetLargerAdjacentDenom(146-151)AmountToDenomination(53-61)
src/coinjoin/client.h (1)
src/coinjoin/client.cpp (10)
JoinExistingQueue(1149-1292)JoinExistingQueue(1149-1150)PreparePromotionEntry(1725-1784)PreparePromotionEntry(1725-1725)PrepareDemotionEntry(1786-1845)PrepareDemotionEntry(1786-1786)ShouldPromote(2316-2349)ShouldPromote(2316-2316)ShouldDemote(2351-2378)ShouldDemote(2351-2351)
src/coinjoin/client.cpp (1)
src/coinjoin/common.h (3)
GetLargerAdjacentDenom(146-151)DenominationToAmount(69-184)AreAdjacentDenominations(135-141)
src/wallet/coinjoin.cpp (2)
src/coinjoin/common.h (1)
DenominationToAmount(69-184)src/wallet/wallet.cpp (2)
GetTxDepthInMainChain(3561-3571)GetTxDepthInMainChain(3561-3561)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
- GitHub Check: arm-linux-build / Build source
- GitHub Check: mac-build / Build source
- GitHub Check: linux64_tsan-build / Build source
- GitHub Check: linux64_nowallet-build / Build source
- GitHub Check: linux64_ubsan-build / Build source
- GitHub Check: linux64_fuzz-build / Build source
- GitHub Check: linux64-build / Build source
- GitHub Check: linux64_sqlite-build / Build source
- GitHub Check: win64-build / Build source
🔇 Additional comments (46)
src/wallet/wallet.h (3)
28-28: LGTM: Include addition is appropriate.The inclusion of
wallet/coincontrol.his necessary to support the newSelectTxDSInsByDenominationoverload at line 547, which usesCoinTypeas a parameter. The existing forward declaration ofCCoinControlat line 124 is insufficient for the enum type.
547-547: LGTM: Overload supports denomination-based selection with coin type filtering.The new overload extends the existing
SelectTxDSInsByDenominationto accept aCoinTypeparameter, enabling more granular control over input selection for promotion/demotion workflows.
565-580: LGTM: Well-documented wallet APIs for promotion/demotion support.The new methods provide essential functionality for the post-V24 promotion/demotion feature:
CountCoinsByDenominationcounts coins by denomination with optional fully-mixed filteringSelectFullyMixedForPromotionselects fully-mixed coins suitable for promotionBoth methods are correctly marked
constand have clear documentation.src/coinjoin/common.h (5)
112-114: LGTM: Clear promotion/demotion constants.The constants are well-defined:
PROMOTION_RATIO = 10reflects the 10:1 ratio between adjacent denominationsGAP_THRESHOLD = 10defines the deficit threshold for triggering promotion/demotion
120-129: LGTM: Correct denomination index lookup.The function correctly maps a bitshifted denomination to its index in
vecStandardDenominations, returning -1 for invalid denominations. The logic and bounds checking are sound.
135-141: LGTM: Correct adjacency check for denominations.The function correctly validates that two denominations are adjacent in the standard denomination list, handling invalid inputs appropriately. This is essential for promotion/demotion validation.
146-151: LGTM: Correct larger adjacent denomination lookup.The function correctly navigates to the larger adjacent denomination, returning 0 when the input is already the largest or invalid. The bitshift calculation is accurate.
156-161: LGTM: Correct smaller adjacent denomination lookup.The function correctly navigates to the smaller adjacent denomination, returning 0 when the input is already the smallest or invalid. The bounds checking and bitshift calculation are accurate.
src/coinjoin/server.cpp (4)
7-9: LGTM: Necessary includes for V24 deployment checks.The includes are required:
chainparams.hfor accessing consensus parameters viaParams()deploymentstatus.hfor theDeploymentActiveAtfunction used in V24 activation checks
228-238: LGTM: Correct V24 gate for promotion/demotion entries.The deployment gate correctly rejects unbalanced (promotion/demotion) entries when V24 is not active. The logic appropriately:
- Detects unbalanced entries by comparing input/output counts
- Checks V24 activation status at the current chain tip
- Rejects with
ERR_MODEwhen the feature is not yet active
607-618: LGTM: Dynamic max inputs based on V24 activation.The code correctly adjusts the maximum entry input count based on V24 activation:
- Pre-V24: limits to
COINJOIN_ENTRY_MAX_SIZE(9)- Post-V24: allows up to
PROMOTION_RATIO(10) for promotion entriesThe logging and error handling are appropriate. This change works in conjunction with the ProcessDSVIN gate to properly support promotion/demotion entries.
228-238: Note: V24 activation checks are performed separately.The code checks V24 activation in both
ProcessDSVIN(line 232) andAddEntry(line 610). While these are called sequentially and the risk is minimal, be aware that the chain tip could theoretically advance between checks. In practice, this is acceptable as it would only result in entry rejection, which is safe.This is an informational note for awareness; verification via testing would confirm the behavior is correct under block transitions.
Also applies to: 607-618
src/coinjoin/coinjoin.h (2)
284-284: LGTM: Signature update for deployment-aware validation.The updated
IsValidStructure(const CBlockIndex* pindex)signature correctly enables V24 deployment detection for promotion/demotion validation. Usingnullptrto indicate pre-fork behavior is a reasonable pattern.
367-388: LGTM: Well-documented validation helper declarations.The new
ValidatePromotionEntryandValidateDemotionEntrydeclarations are properly documented with clear parameter semantics. The API correctly identifies the session denomination role (smaller denom for both cases) and returns validation status via the out-parameter.src/wallet/coinjoin.cpp (3)
48-51: LGTM: Clean delegation for backward compatibility.The existing 3-parameter overload now delegates to the new 4-parameter version with
CoinType::ONLY_READY_TO_MIXas default. This preserves existing behavior while enabling the new coin type flexibility.
53-68: LGTM: New overload with configurable coin type.The new overload correctly accepts
CoinTypeand passes it toCCoinControl. The enhanced logging at line 68 is helpful for debugging coin selection with different coin types.
414-441: LGTM: CountCoinsByDenomination implementation is correct.The function properly:
- Guards on
CCoinJoinClientOptions::IsEnabled()- Validates denomination via
DenominationToAmount- Uses depth check
< 1to skip unconfirmed/conflicted (consistent with line 462)- Correctly handles the
fFullyMixedOnlyfiltersrc/coinjoin/client.h (4)
146-150: LGTM: Promotion/demotion session state additions.The new session state members are properly initialized:
m_fPromotion{false}andm_fDemotion{false}with brace initializersm_vecPromotionInputsas an empty vectorThese flags enable session-level tracking of the entry type being prepared.
176-183: LGTM: Entry preparation methods with proper lock annotations.Both
PreparePromotionEntryandPrepareDemotionEntrycorrectly requirem_wallet->cs_walletlock, consistent withPrepareDenominateabove. The separation of promotion (10→1) and demotion (1→10) preparation is clean.
335-350: LGTM: Manager-level decision helpers with clear documentation.The
ShouldPromoteandShouldDemotemethods are properly documented with parameter semantics. These provide the decision logic for when to trigger promotion/demotion based on denomination counts and goals.
164-168: Verify SetNull() clears new state members.The new parameters to
JoinExistingQueueandStartNewQueuecorrectly extend the API with defaults for backward compatibility. EnsureSetNull()in the session class properly resetsm_fPromotion,m_fDemotion, andm_vecPromotionInputsbefore merging.src/test/coinjoin_inouts_tests.cpp (7)
52-86: LGTM: Updated tests for pindex-aware IsValidStructure.The tests correctly pass
nullptrtoIsValidStructure()to test pre-V24 behavior, matching the updated signature. The test cases cover:
- Valid structure
- Invalid identifiers
- Size mismatch (pre-V24 rejection)
- Invalid scripts and amounts
Good coverage of pre-fork validation paths.
137-149: LGTM: Clean test helper functions.
MakeDenomInputandMakeDenomOutputprovide reusable helpers for constructing test inputs/outputs. The helpers correctly useP2PKHScriptfor valid script generation.
151-175: LGTM: Valid promotion entry test.The test correctly validates a promotion entry with:
- 10 inputs (
PROMOTION_RATIO)- 1 output at the larger adjacent denomination
- Proper denomination adjacency (0.1 → 1.0 DASH)
477-500: Critical test for value preservation across denominations.This test validates a key invariant:
nSmaller * PROMOTION_RATIO == nLargerfor all adjacent denomination pairs. This ensures promotion/demotion preserves exact value.
722-756: Test helper mirrors implementation logic correctly.The
TestShouldPromoteandTestShouldDemotehelpers correctly implement the decision algorithm with:
- Half-goal threshold check
- Deficit calculation (
max(0, goal - count))- Gap threshold comparison
This enables testing the algorithm logic without wallet dependencies.
827-861: Thorough mutual exclusivity testing.The
promote_demote_mutually_exclusivetest correctly validates that for any count distribution, at most one of promote/demote can be true. The use of structured bindings (auto [p, d] = ...) is clean C++17 style.
1019-1039: Good documentation of functional test requirements.The comment block properly documents that post-V24 behaviors requiring EHF activation cannot be unit tested and require functional tests. This sets clear expectations for test coverage boundaries.
src/coinjoin/coinjoin.cpp (7)
9-9: LGTM: Required include for deployment status checks.The
deploymentstatus.hinclude is necessary for theDeploymentActiveAtcalls used in V24 activation checks.
93-136: LGTM: IsValidStructure correctly handles V24 deployment.The implementation correctly:
- Uses
DeploymentActiveAtwith pindex for V24 detection- Pre-V24: Requires balanced vin/vout counts
- Post-V24: Allows unbalanced for promotion/demotion
- Dynamically adjusts max inputs (180 pre-fork, 200 post-fork)
- Validates all outputs are denominated and P2PKH
The comment about value sum validation being deferred to
IsValidInOutsis accurate since it requires UTXO access.
221-260: LGTM: Entry type detection with proper V24 gating.The entry type detection logic correctly:
- Identifies STANDARD (balanced) entries for all versions
- Only allows PROMOTION/DEMOTION post-V24
- Properly rejects invalid structures with ERR_SIZE_MISMATCH
The local
EntryTypeenum provides clear semantics.
261-288: LGTM: Denomination expectations set correctly per entry type.For each entry type:
- STANDARD: inputs and outputs at session denom
- PROMOTION: inputs at session denom, output at larger adjacent
- DEMOTION: input at larger adjacent, outputs at session denom
The early failure when
nLargerAdjacentDenom == 0prevents invalid promotion/demotion from the largest/smallest denominations.
351-363: LGTM: Value preservation check applies to all entry types.The
nFees != 0check correctly ensures total input value equals total output value. This is essential for promotion/demotion where 10 × smaller_denom == larger_denom must hold exactly.The logging now includes entry type, which aids debugging.
583-633: LGTM: ValidatePromotionEntry implementation is correct.The function properly validates:
- Exactly
PROMOTION_RATIOinputs- Exactly 1 output
- Larger adjacent denomination exists
- Output matches larger denomination
- Output is P2PKH
Note: Input denomination validation is not performed here as this function validates structure, not UTXO values (done in
IsValidInOuts).
635-675: LGTM: ValidateDemotionEntry correctly validates structure.The function properly validates:
- Exactly 1 input
- Exactly
PROMOTION_RATIOoutputs- All outputs at session denomination
- All outputs are P2PKH
Similar to promotion, input denomination validation is deferred to
IsValidInOutswhere UTXO access is available.src/coinjoin/client.cpp (11)
11-11: LGTM!The new includes for
deploymentstatus.handvalidation.hare appropriate for the V24 deployment detection logic added in this PR.Also applies to: 25-25
295-299: LGTM!Proper cleanup of the new promotion/demotion session state fields, with the lock assertion already in place from the existing code.
979-1035: Clarify target denomination semantics for queues.The V24 detection and iteration logic looks correct. However, I want to verify the target denomination semantics:
- For promotion (lines 1007, 1011):
nSmallerDenomis passed as target - this makes sense as the queue is for the smaller denomination that gets promoted to larger.- For demotion (lines 1026, 1030):
nSmallerDenomis also passed - this means the queue denomination is the output denomination, not the input.This is consistent with the queue matching in
JoinExistingQueueat line 1185 which checksdsq.nDenom != nTargetDenom. The session denomination represents what the mixing session produces, which for demotion is the smaller denomination.The logic is correct but the naming could be clearer. The
nTargetDenomeffectively means "the denomination this session is operating on" which works for both promotion inputs and demotion outputs.
1575-1594: LGTM!Clean separation of promotion/demotion submission paths from standard mixing. The error handling properly logs failures and sets the status message.
1725-1784: LGTM!The promotion entry preparation correctly implements the 10:1 ratio:
- 10 inputs from fully-mixed smaller denomination coins
- 1 output of the larger adjacent denomination
- Empty
CTxOut()placeholders are properly filtered out inSendDenominate- Input locking prevents double-spending during the session
1786-1845: LGTM with a note on denomination semantics.The demotion entry preparation correctly implements the 1:10 ratio:
- 1 input from the larger denomination coin
- 10 outputs of the smaller denomination
- Empty
CTxDSIn()placeholders are properly filtered inSendDenominateThe denomination logic is correct:
nSessionDenomis the target denomination (smaller, for outputs), andGetLargerAdjacentDenom(nSessionDenom)correctly identifies the input denomination for validation.
2316-2349: Well-designed promotion decision logic.The algorithm correctly:
- Validates denomination adjacency
- Protects denominations still being built (< half goal)
- Uses a gap threshold to prevent oscillation
- Requires fully-mixed coins to maintain anonymity guarantees
The hysteresis via
GAP_THRESHOLDis a good design choice to prevent thrashing between promotion and demotion.
2351-2378: LGTM!The demotion decision logic correctly mirrors promotion but without the fully-mixed requirement. This asymmetry is intentional and makes sense:
- Promotion requires fully-mixed coins to preserve anonymity (10 mixed → 1 mixed)
- Demotion can use any denominated coin since splitting doesn't reduce anonymity (1 → 10 new outputs)
The gap threshold hysteresis is shared, preventing oscillation.
1192-1241: Verify wallet lock consistency between promotion and demotion paths.For the promotion path (lines 1192-1213), you explicitly acquire
m_wallet->cs_walletat line 1200 to safely accessmapWallet. However, for the demotion path (lines 1214-1234), you callSelectTxDSInsByDenominationwithout an explicit lock in this scope. Confirm whetherSelectTxDSInsByDenominationacquires the wallet lock internally or if explicit locking is needed here to match the promotion path's pattern.
1576-1594: Resource cleanup on preparation failure follows existing timeout-based pattern.In the promotion/demotion paths (lines 1577–1594), if
PreparePromotionEntryorPrepareDemotionEntryfails, locked coins remain locked until the session times out and callsSetNull() → UnlockCoins(). This matches the pattern used in standard mixing paths. Consider whether immediate cleanup would improve user experience by freeing coins sooner, though the current approach is consistent with the codebase design.
499-510: Remove references to non-existent preparation functions and correct CTxOut behavior.The filtering logic uses
prevout.IsNull()andnValue > 0to distinguish placeholder entries from real transaction data. However, the original review contains factual errors:
- CTxOut's default constructor calls
SetNull(), settingnValueto-1(not0), so the checkif (txOut.nValue > 0)correctly filters out both default (-1) and zero-value outputs.- The referenced functions
PreparePromotionEntryandPrepareDemotionEntrydo not exist in the Dash Core repository and cannot be verified against this filtering logic.The actual filtering appears sound for handling asymmetric input/output counts in promotion/demotion scenarios, but the justification in the original comment is incorrect.
Likely an incorrect or invalid review comment.
Fixes for PR dashpay#7052: Issue #1 (CRITICAL): Fix race condition in coin selection - Lock coins immediately after selection in JoinExistingQueue/StartNewQueue - Prevents concurrent sessions from selecting the same coins - Add defensive IsLockedCoin check in Prepare functions Issue #2 (CRITICAL): Fix resource leak on session failure - Add promotion inputs to vecOutPointLocked in SetNull() - Ensures coins locked early are properly unlocked if session fails - Leverages existing UnlockCoins() retry mechanism Issue #3: Add UTXO validation before use - Check IsSpent() before using promotion/demotion inputs - Prevents using externally spent or transferred coins 🤖 Generated with [Claude Code](https://claude.com/claude-code)
b70ccef to
d140732
Compare
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
src/coinjoin/client.cpp (1)
1789-1800: Optional: Simplify promotion output pairing for clarity.The current approach creates 10 input-output pairs with empty outputs (line 1793), then replaces the last pair's output (line 1800). While correct, this is less clear than creating the pairs with the appropriate output from the start.
🔎 Alternative approach
- // For promotion, outputs are created but only 1 matters (the larger denom) - // We'll use empty CTxOut for all but the first to signal "no output for this input" - // Actually, for promotion entry: 10 inputs, 1 output - // We need to pair each input with an "empty" output, except the last gets the real output - vecPSInOutPairsRet.emplace_back(txdsin, CTxOut()); -} - -// Now set the single output (larger denomination) on the last entry -CScript scriptDenom = keyHolderStorage.AddKey(m_wallet.get()); -if (!vecPSInOutPairsRet.empty()) { - // Replace the last output with the actual promotion output - vecPSInOutPairsRet.back().second = CTxOut(nLargerAmount, scriptDenom); + // For promotion: 10 inputs, 1 output (on last pair) + if (i == static_cast<int>(m_vecPromotionInputs.size()) - 1) { + // Last input gets the real promotion output + CScript scriptDenom = keyHolderStorage.AddKey(m_wallet.get()); + vecPSInOutPairsRet.emplace_back(txdsin, CTxOut(nLargerAmount, scriptDenom)); + } else { + // Other inputs get empty outputs + vecPSInOutPairsRet.emplace_back(txdsin, CTxOut()); + } + }This makes the intent clearer without changing behavior.
📜 Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
📥 Commits
Reviewing files that changed from the base of the PR and between ffb2e13 and d140732ef87897c32385398fa7dca34fa4784091.
📒 Files selected for processing (10)
src/coinjoin/client.cpp(12 hunks)src/coinjoin/client.h(3 hunks)src/coinjoin/coinjoin.cpp(6 hunks)src/coinjoin/coinjoin.h(4 hunks)src/coinjoin/common.h(1 hunks)src/coinjoin/server.cpp(4 hunks)src/net_processing.cpp(1 hunks)src/test/coinjoin_inouts_tests.cpp(4 hunks)src/wallet/coinjoin.cpp(3 hunks)src/wallet/wallet.h(3 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
- src/net_processing.cpp
- src/coinjoin/common.h
- src/wallet/wallet.h
🧰 Additional context used
📓 Path-based instructions (6)
src/**/*.{cpp,h,hpp,cc}
📄 CodeRabbit inference engine (CLAUDE.md)
Dash Core implementation must be written in C++20, requiring at least Clang 16 or GCC 11.1
Files:
src/coinjoin/server.cppsrc/test/coinjoin_inouts_tests.cppsrc/coinjoin/client.hsrc/coinjoin/coinjoin.hsrc/wallet/coinjoin.cppsrc/coinjoin/coinjoin.cppsrc/coinjoin/client.cpp
src/{masternode,evo,llmq,governance,coinjoin}/**/*.{cpp,h}
📄 CodeRabbit inference engine (CLAUDE.md)
Use Dash-specific database implementations: CFlatDB for persistent storage (MasternodeMetaStore, GovernanceStore, SporkStore, NetFulfilledRequestStore) and CDBWrapper extensions for Evolution/DKG/InstantSend/Quorum/RecoveredSigs data
Files:
src/coinjoin/server.cppsrc/coinjoin/client.hsrc/coinjoin/coinjoin.hsrc/coinjoin/coinjoin.cppsrc/coinjoin/client.cpp
src/coinjoin/**/*.{cpp,h}
📄 CodeRabbit inference engine (CLAUDE.md)
CoinJoin implementation must use masternode-coordinated mixing sessions with uniform denomination outputs
Files:
src/coinjoin/server.cppsrc/coinjoin/client.hsrc/coinjoin/coinjoin.hsrc/coinjoin/coinjoin.cppsrc/coinjoin/client.cpp
src/{masternode,llmq,evo,coinjoin,governance}/**/*.{cpp,h}
📄 CodeRabbit inference engine (CLAUDE.md)
Use unordered_lru_cache for efficient caching with LRU eviction in Dash-specific data structures
Files:
src/coinjoin/server.cppsrc/coinjoin/client.hsrc/coinjoin/coinjoin.hsrc/coinjoin/coinjoin.cppsrc/coinjoin/client.cpp
src/{test,wallet/test}/**/*.{cpp,h}
📄 CodeRabbit inference engine (CLAUDE.md)
Unit tests in src/test/ and src/wallet/test/ must use Boost::Test framework
Files:
src/test/coinjoin_inouts_tests.cpp
src/wallet/**/*.{cpp,h}
📄 CodeRabbit inference engine (CLAUDE.md)
Wallet implementation must use Berkeley DB and SQLite
Files:
src/wallet/coinjoin.cpp
🧠 Learnings (19)
📓 Common learnings
Learnt from: kwvg
Repo: dashpay/dash PR: 6543
File: src/wallet/receive.cpp:240-251
Timestamp: 2025-02-06T14:34:30.466Z
Learning: Pull request #6543 is focused on move-only changes and refactoring, specifically backporting from Bitcoin. Behavior changes should be proposed in separate PRs.
Learnt from: CR
Repo: dashpay/dash PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-24T16:41:22.457Z
Learning: Applies to src/coinjoin/**/*.{cpp,h} : CoinJoin implementation must use masternode-coordinated mixing sessions with uniform denomination outputs
Learnt from: CR
Repo: dashpay/dash PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-24T16:41:22.457Z
Learning: Applies to src/{validation,txmempool}/**/*.{cpp,h} : Block validation and mempool handling must use extensions to Bitcoin Core mechanisms for special transaction validation and enhanced transaction relay
📚 Learning: 2025-11-24T16:41:22.457Z
Learnt from: CR
Repo: dashpay/dash PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-24T16:41:22.457Z
Learning: Applies to src/coinjoin/**/*.{cpp,h} : CoinJoin implementation must use masternode-coordinated mixing sessions with uniform denomination outputs
Applied to files:
src/coinjoin/server.cppsrc/test/coinjoin_inouts_tests.cppsrc/coinjoin/client.hsrc/coinjoin/coinjoin.hsrc/wallet/coinjoin.cppsrc/coinjoin/coinjoin.cppsrc/coinjoin/client.cpp
📚 Learning: 2025-11-24T16:41:22.457Z
Learnt from: CR
Repo: dashpay/dash PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-24T16:41:22.457Z
Learning: Applies to src/{validation,txmempool}/**/*.{cpp,h} : Block validation and mempool handling must use extensions to Bitcoin Core mechanisms for special transaction validation and enhanced transaction relay
Applied to files:
src/coinjoin/server.cppsrc/test/coinjoin_inouts_tests.cppsrc/coinjoin/coinjoin.hsrc/coinjoin/coinjoin.cppsrc/coinjoin/client.cpp
📚 Learning: 2025-06-06T11:53:09.094Z
Learnt from: kwvg
Repo: dashpay/dash PR: 6665
File: src/evo/providertx.h:82-82
Timestamp: 2025-06-06T11:53:09.094Z
Learning: In ProTx serialization code (SERIALIZE_METHODS), version checks should use hardcoded maximum flags (/*is_basic_scheme_active=*/true, /*is_extended_addr=*/true) rather than deployment-based flags. This is because serialization code should be able to deserialize any structurally valid ProTx up to the maximum version the code knows how to handle, regardless of current consensus validity. Validation code, not serialization code, is responsible for checking whether a ProTx version is consensus-valid based on deployment status.
Applied to files:
src/coinjoin/server.cppsrc/coinjoin/coinjoin.cpp
📚 Learning: 2025-11-24T16:41:22.457Z
Learnt from: CR
Repo: dashpay/dash PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-24T16:41:22.457Z
Learning: Applies to src/{masternode,llmq}/**/*.{cpp,h} : BLS integration must be used for cryptographic foundation of advanced masternode features
Applied to files:
src/coinjoin/server.cppsrc/coinjoin/client.cpp
📚 Learning: 2025-11-24T16:41:22.457Z
Learnt from: CR
Repo: dashpay/dash PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-24T16:41:22.457Z
Learning: Applies to src/node/chainstate.{cpp,h} : Chainstate initialization must be separated into dedicated src/node/chainstate.* files
Applied to files:
src/coinjoin/server.cppsrc/coinjoin/coinjoin.cppsrc/coinjoin/client.cpp
📚 Learning: 2025-11-24T16:41:22.457Z
Learnt from: CR
Repo: dashpay/dash PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-24T16:41:22.457Z
Learning: Applies to src/{masternode,evo,llmq,governance,coinjoin}/**/*.{cpp,h} : Use Dash-specific database implementations: CFlatDB for persistent storage (MasternodeMetaStore, GovernanceStore, SporkStore, NetFulfilledRequestStore) and CDBWrapper extensions for Evolution/DKG/InstantSend/Quorum/RecoveredSigs data
Applied to files:
src/coinjoin/server.cppsrc/coinjoin/client.cpp
📚 Learning: 2025-11-24T16:41:22.457Z
Learnt from: CR
Repo: dashpay/dash PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-24T16:41:22.457Z
Learning: Applies to src/{test,wallet/test}/**/*.{cpp,h} : Unit tests in src/test/ and src/wallet/test/ must use Boost::Test framework
Applied to files:
src/test/coinjoin_inouts_tests.cpp
📚 Learning: 2025-06-09T16:43:20.996Z
Learnt from: kwvg
Repo: dashpay/dash PR: 6718
File: test/functional/test_framework/test_framework.py:2102-2102
Timestamp: 2025-06-09T16:43:20.996Z
Learning: In the test framework consolidation PR (#6718), user kwvg prefers to limit functional changes to those directly related to MasternodeInfo, avoiding scope creep even for minor improvements like error handling consistency.
Applied to files:
src/test/coinjoin_inouts_tests.cpp
📚 Learning: 2025-05-05T12:45:44.781Z
Learnt from: knst
Repo: dashpay/dash PR: 6658
File: src/evo/creditpool.cpp:177-185
Timestamp: 2025-05-05T12:45:44.781Z
Learning: The GetAncestor() function in CBlockIndex safely handles negative heights by returning nullptr rather than asserting, making it safe to call with potentially negative values.
Applied to files:
src/coinjoin/coinjoin.h
📚 Learning: 2025-11-24T16:41:22.457Z
Learnt from: CR
Repo: dashpay/dash PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-24T16:41:22.457Z
Learning: Applies to src/{validation,consensus,net_processing}/**/*.{cpp,h} : ValidationInterface callbacks must be used for event-driven architecture to coordinate subsystems during block/transaction processing
Applied to files:
src/coinjoin/coinjoin.cppsrc/coinjoin/client.cpp
📚 Learning: 2025-08-08T07:01:47.332Z
Learnt from: knst
Repo: dashpay/dash PR: 6805
File: src/wallet/rpc/wallet.cpp:357-357
Timestamp: 2025-08-08T07:01:47.332Z
Learning: In src/wallet/rpc/wallet.cpp, the upgradetohd RPC now returns a UniValue string message (RPCResult::Type::STR) instead of a boolean, including guidance about mnemonic backup and null-character passphrase handling; functional tests have been updated to assert returned strings in several cases.
Applied to files:
src/coinjoin/coinjoin.cpp
📚 Learning: 2025-07-09T15:02:26.899Z
Learnt from: kwvg
Repo: dashpay/dash PR: 6729
File: src/evo/deterministicmns.cpp:1313-1316
Timestamp: 2025-07-09T15:02:26.899Z
Learning: In Dash's masternode transaction validation, `IsVersionChangeValid()` is only called by transaction types that update existing masternode entries (like `ProUpServTx`, `ProUpRegTx`, `ProUpRevTx`), not by `ProRegTx` which creates new entries. This means validation logic in `IsVersionChangeValid()` only applies to the subset of transaction types that actually call it, not all masternode transaction types.
Applied to files:
src/coinjoin/coinjoin.cpp
📚 Learning: 2025-11-24T16:41:22.457Z
Learnt from: CR
Repo: dashpay/dash PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-24T16:41:22.457Z
Learning: Applies to src/evo/evodb/**/*.{cpp,h} : Evolution Database (CEvoDb) must handle masternode snapshots, quorum state, governance objects with efficient differential updates for masternode lists
Applied to files:
src/coinjoin/client.cpp
📚 Learning: 2025-11-25T10:53:37.523Z
Learnt from: knst
Repo: dashpay/dash PR: 7008
File: src/masternode/sync.h:17-18
Timestamp: 2025-11-25T10:53:37.523Z
Learning: The file src/masternode/sync.h containing `CMasternodeSync` is misnamed and misplaced—it has nothing to do with "masternode" functionality. It should eventually be renamed to `NodeSyncing` or `NodeSyncStatus` and moved to src/node/sync.h as a future refactoring.
Applied to files:
src/coinjoin/client.cpp
📚 Learning: 2025-11-24T16:41:22.457Z
Learnt from: CR
Repo: dashpay/dash PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-24T16:41:22.457Z
Learning: Applies to src/{masternode,evo}/**/*.{cpp,h} : Masternode lists must use immutable data structures (Immer library) for thread safety
Applied to files:
src/coinjoin/client.cpp
📚 Learning: 2025-11-24T16:41:22.457Z
Learnt from: CR
Repo: dashpay/dash PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-24T16:41:22.457Z
Learning: Applies to src/evo/**/*.{cpp,h} : Special transactions use payload serialization routines defined in src/evo/specialtx.h and must include appropriate special transaction types (ProRegTx, ProUpServTx, ProUpRegTx, ProUpRevTx)
Applied to files:
src/coinjoin/client.cpp
📚 Learning: 2025-11-24T16:41:22.457Z
Learnt from: CR
Repo: dashpay/dash PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-24T16:41:22.457Z
Learning: Applies to src/wallet/**/*.{cpp,h} : Wallet implementation must use Berkeley DB and SQLite
Applied to files:
src/coinjoin/client.cpp
📚 Learning: 2025-02-14T15:19:17.218Z
Learnt from: kwvg
Repo: dashpay/dash PR: 6529
File: src/wallet/rpcwallet.cpp:3002-3003
Timestamp: 2025-02-14T15:19:17.218Z
Learning: The `GetWallet()` function calls in `src/wallet/rpcwallet.cpp` are properly validated with null checks that throw appropriate RPC errors, making additional validation unnecessary.
Applied to files:
src/coinjoin/client.cpp
🧬 Code graph analysis (7)
src/coinjoin/server.cpp (2)
src/coinjoin/coinjoin.h (1)
GetStandardEntriesCount(330-341)src/coinjoin/coinjoin.cpp (2)
GetMinPoolParticipants(579-579)GetMinPoolParticipants(579-579)
src/test/coinjoin_inouts_tests.cpp (2)
src/coinjoin/common.h (6)
DenominationToAmount(69-184)IsValidDenomination(102-102)AreAdjacentDenominations(135-141)GetLargerAdjacentDenom(146-151)GetSmallerAdjacentDenom(156-161)GetSmallestDenomination(47-47)src/coinjoin/coinjoin.cpp (4)
ValidatePromotionEntry(582-631)ValidatePromotionEntry(582-583)ValidateDemotionEntry(633-673)ValidateDemotionEntry(633-634)
src/coinjoin/client.h (1)
src/coinjoin/client.cpp (14)
JoinExistingQueue(1159-1307)JoinExistingQueue(1159-1160)StartNewQueue(1309-1388)StartNewQueue(1309-1309)StartNewQueue(1390-1517)StartNewQueue(1390-1391)PreparePromotionEntry(1748-1817)PreparePromotionEntry(1748-1748)PrepareDemotionEntry(1819-1888)PrepareDemotionEntry(1819-1819)ShouldPromote(2359-2392)ShouldPromote(2359-2359)ShouldDemote(2394-2421)ShouldDemote(2394-2394)
src/coinjoin/coinjoin.h (1)
src/coinjoin/coinjoin.cpp (7)
nodiscard(64-68)IsValidStructure(93-134)IsValidStructure(93-93)ValidatePromotionEntry(582-631)ValidatePromotionEntry(582-583)ValidateDemotionEntry(633-673)ValidateDemotionEntry(633-634)
src/wallet/coinjoin.cpp (4)
src/coinjoin/client.cpp (1)
coin_control(2022-2022)src/rpc/masternode.cpp (1)
coin_control(163-163)src/coinjoin/common.h (2)
nDenomAmount(88-98)DenominationToAmount(69-184)src/wallet/wallet.cpp (2)
GetTxDepthInMainChain(3588-3598)GetTxDepthInMainChain(3588-3588)
src/coinjoin/coinjoin.cpp (1)
src/coinjoin/common.h (3)
IsDenominatedAmount(101-101)GetLargerAdjacentDenom(146-151)AmountToDenomination(53-61)
src/coinjoin/client.cpp (3)
src/wallet/coinjoin.cpp (2)
outpoint(574-574)outpoint(624-624)src/wallet/interfaces.cpp (4)
outpoint(399-399)outpoint(399-399)outpoint(400-400)outpoint(400-400)src/coinjoin/common.h (3)
GetLargerAdjacentDenom(146-151)DenominationToAmount(69-184)AreAdjacentDenominations(135-141)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
- GitHub Check: linux64-test / Test source
- GitHub Check: linux64_sqlite-test / Test source
- GitHub Check: linux64_ubsan-test / Test source
- GitHub Check: linux64_tsan-test / Test source
- GitHub Check: linux64_multiprocess-build / Build source
🔇 Additional comments (29)
src/wallet/coinjoin.cpp (3)
48-51: LGTM: Clean delegation pattern.The 4-parameter overload correctly delegates to the new 5-parameter version with the appropriate default
CoinType::ONLY_READY_TO_MIX, maintaining backward compatibility.
53-93: LGTM: CoinType parameterization enables promotion/demotion flows.The new 5-parameter overload correctly propagates the
CoinTypetoCCoinControland includes it in logging. The implementation maintains the original coin selection logic while adding flexibility for different coin type filtering.
414-441: Verify depth check semantics.The depth check
GetTxDepthInMainChain(it->second) < 1filters unconfirmed or conflicted transactions. According toGetTxDepthInMainChainsemantics, this function returns positive depth for confirmed transactions, 0 for unconfirmed, and negative depth for conflicted transactions. The< 1check correctly keeps only confirmed transactions with depth >= 1.src/coinjoin/server.cpp (3)
228-238: LGTM: Correct V24 deployment gating for promotion/demotion.The pre-check correctly identifies unbalanced entries (promotion/demotion) and rejects them when V24 is not active. The use of
ERR_MODEis appropriate for incompatible mode rejection, and the nullpindexcheck prevents potential crashes.
303-306: LGTM: Critical privacy protection for promotion/demotion flows.The change from
GetEntriesCount()toGetStandardEntriesCount()is essential for privacy. Promotion/demotion entries should not count toward the minimum participant threshold because they don't contribute to the anonymity set—they rely on standard mixing participants for privacy. The inline comment clearly explains this rationale.
609-620: LGTM: Deployment-aware entry input limits.The V24-aware maximum input calculation correctly increases the limit to
PROMOTION_RATIO(10) for post-V24 promotion entries while maintaining the pre-V24 limit ofCOINJOIN_ENTRY_MAX_SIZE(9). The error logging includes the actual limit for clarity.src/coinjoin/coinjoin.h (4)
172-179: LGTM: Clear entry type classification.The
IsStandardMixingEntry()method provides a simple and correct classification based on input/output balance. The inline implementation is appropriate for this trivial check, and the comment clearly explains the three entry types.
329-341: LGTM: Correct lock annotations and idiomatic implementation.The standard entry counting methods correctly use thread-safety annotations to prevent deadlocks. The locked variant (
GetStandardEntriesCountLocked) is provided for callers already holding the lock, and the implementation uses idiomaticstd::count_if. The comment clearly explains the privacy threshold purpose.
391-411: LGTM: Well-documented validation API.The promotion/demotion validation functions are cleanly declared in the
CoinJoinnamespace with comprehensive documentation. The parameter list is appropriate for the validation task, and the pattern of returningboolwith an out-parameternMessageIDRetis consistent with existing code.
293-293: All call sites have been properly updated for the signature change. The production code insrc/net_processing.cpp:3532passes the block index parameter, while test code appropriately passesnullptrfor pre-fork validation testing.src/test/coinjoin_inouts_tests.cpp (5)
52-87: LGTM: Correct pre-fork testing pattern.The tests correctly use
nullptrfor thepindexparameter to simulate pre-V24 behavior. This aligns with the implementation inIsValidStructurewhere!fV24Activeresults in pre-fork validation rules. The comments clearly document this pattern.
138-149: LGTM: Clean test helper utilities.The
MakeDenomInputandMakeDenomOutputhelper functions provide clean abstractions that reduce test boilerplate while ensuring unique prevouts and correct P2PKH scripts as required by CoinJoin.
469-492: LGTM: Critical value preservation invariant.This test verifies the fundamental invariant that
10 * smaller == largerexactly for all adjacent denomination pairs. This is essential for promotion/demotion to preserve value without fees. The test correctly covers all adjacent pairs in the denomination ladder.
684-856: LGTM: Comprehensive promotion/demotion decision algorithm tests.The test suite thoroughly covers the promotion/demotion decision algorithms:
- HalfGoal threshold prevents sacrificing denominations being built up
- GAP_THRESHOLD ensures sufficient deficit gap before action
- Mutual exclusivity tests confirm at most one action triggers
- Specific examples from the implementation plan are verified
These tests provide confidence in the correctness of the decision logic that will balance denomination distributions.
982-1021: LGTM: Privacy threshold counting verification.This test verifies that
GetStandardEntriesCountcorrectly excludes promotion/demotion entries from the count, returning only the standard mixing entries (3 of 5 total). This aligns with the privacy protection logic in the server where only standard entries count toward the minimum threshold.src/coinjoin/client.h (4)
164-168: LGTM: Clean API extension with backward compatibility.The queue method signatures are extended with default parameters (
nTargetDenom = 0, fPromotion = false, fDemotion = false), maintaining backward compatibility while enabling promotion/demotion flows. The overloading pattern keeps the API clean.
177-183: LGTM: Well-documented preparation methods with correct lock annotations.The
PreparePromotionEntryandPrepareDemotionEntrymethods are properly annotated withEXCLUSIVE_LOCKS_REQUIRED(m_wallet->cs_wallet)to ensure thread safety. The API follows the existing pattern of returningboolwith a string error message and output parameters for the constructed entry pairs.
336-350: LGTM: Clean decision API with comprehensive documentation.The
ShouldPromoteandShouldDemotemethods provide a clean decision API. They are correctly markedconstsince they only inspect state without modification. The comprehensive documentation clearly explains the parameters and return values.
146-149: Mutual exclusivity of promotion/demotion flags is enforced.The implementation correctly ensures mutual exclusivity:
JoinExistingQueueincludes an explicit assertion:assert(!(fPromotion && fDemotion))at line 1166StartNewQueueenforces it through control flow withif (fPromotion) { ... } else if (fDemotion) { ... } else { return false; }at lines 1400-1444- All callers pass mutually exclusive values (one true, one false)
src/coinjoin/coinjoin.cpp (4)
93-134: LGTM: Correct V24-aware structure validation.The
IsValidStructureimplementation correctly:
- Computes V24 activation via
DeploymentActiveAtwith nullpindexhandling- Rejects unbalanced entries pre-V24 (line 104)
- Adjusts max inputs: 200 post-V24 vs 180 pre-V24 (lines 114-116)
- Validates all outputs are valid denominations and P2PKH (lines 123-127)
- Documents that value sum validation is deferred to
IsValidInOuts(lines 129-131)The implementation aligns with the test patterns that use
nullptrfor pre-fork scenarios.
210-363: LGTM: Comprehensive entry validation with proper V24 support.The extended
IsValidInOutsimplementation provides thorough validation:
- V24 activation check (lines 220-225): Correctly acquires
cs_mainto access chain tip- Entry classification (lines 227-257): Properly categorizes STANDARD/PROMOTION/DEMOTION/INVALID
- Denomination validation (lines 259-285): Correctly determines expected denoms based on entry type
- Script validation (lines 287-311): Validates P2PKH requirement and prevents duplicate scripts
- UTXO validation (lines 324-347): Ensures inputs exist and are spendable
- Fee enforcement (lines 349-355): Correctly requires zero fees for CoinJoin
- Logging (lines 357-362): Includes entry type for debugging
The implementation correctly handles the complexity of three entry types while maintaining the core CoinJoin guarantees.
582-631: LGTM: Thorough promotion entry validation.The
ValidatePromotionEntryimplementation correctly validates:
- Input count must be exactly
PROMOTION_RATIO(10)- Output count must be exactly 1
- Larger adjacent denomination must exist (not largest)
- Output denomination must match the larger adjacent denomination
- Output must be P2PKH script
Error messages are descriptive and use
LogPrintfor debugging. The validation prevents malformed promotion entries from entering the pool.
633-673: LGTM: Complete demotion entry validation.The
ValidateDemotionEntryimplementation correctly validates:
- Input count must be exactly 1
- Output count must be exactly
PROMOTION_RATIO(10)- All outputs must be at session denomination (smaller)
- All outputs must be P2PKH scripts
The loop at lines 657-670 correctly validates all outputs, not just the first, ensuring the entire demotion structure is valid.
src/coinjoin/client.cpp (6)
11-11: LGTM!The new includes support V24 deployment detection and chain state access required by the promotion/demotion logic.
Also applies to: 25-25
510-525: LGTM! Filtering approach correctly handles promotion/demotion entry structure.The filtering removes empty placeholder inputs/outputs from promotion (10 inputs → 1 output) and demotion (1 input → 10 outputs) entries before relay. The updated log reflects actual counts submitted.
991-1045: Verify denomination promotion/demotion priority order is intentional.The loop processes adjacent denomination pairs in order i=0 to i=3, which corresponds to:
- i=0: 10 DASH ↔ 1 DASH
- i=1: 1 DASH ↔ 0.1 DASH
- i=2: 0.1 DASH ↔ 0.01 DASH
- i=3: 0.01 DASH ↔ 0.001 DASH
Since the function returns on first successful promotion/demotion (lines 1019, 1023, 1037, 1041), larger denominations have priority. Confirm this matches the intended behavior, or consider processing in reverse order to prioritize completing smaller denominations first (which may be more common operations).
1598-1617: LGTM! Clear separation of promotion/demotion and standard mixing flows.The conditional logic correctly routes to
PreparePromotionEntry/PrepareDemotionEntryfor V24 flows and falls back to standard mixing otherwise. Error handling is appropriate.
1768-1794: Add bounds checking and remove redundant IsSpent check.Line 1775 accesses
wtx.tx->vout[outpoint.n]without validating bounds (same issue appears at line 1846 in PrepareDemotionEntry).Additionally, the
IsSpentcheck at lines 1781-1784 is redundant since coins inm_vecPromotionInputsare already locked (preventing spending). If a locked coin is spent, that indicates a critical wallet inconsistency that should be handled differently (assertion or stronger error).🔎 Add bounds check and consider removing redundant IsSpent
const wallet::CWalletTx& wtx = it->second; + if (outpoint.n >= wtx.tx->vout.size()) { + strErrorRet = "Invalid promotion input index"; + return false; + } - // Validate the UTXO is still spendable - if (m_wallet->IsSpent(outpoint)) { - strErrorRet = "Promotion input has been spent"; - return false; - } + // Locked coins should never be spent - assert if this occurs + assert(!m_wallet->IsSpent(outpoint));Apply similar changes to PrepareDemotionEntry at lines 1846-1855.
⛔ Skipped due to learnings
Learnt from: CR Repo: dashpay/dash PR: 0 File: CLAUDE.md:0-0 Timestamp: 2025-11-24T16:41:22.457Z Learning: Applies to src/coinjoin/**/*.{cpp,h} : CoinJoin implementation must use masternode-coordinated mixing sessions with uniform denomination outputs
295-311: SetNull() properly handles promotion/demotion input unlocking with appropriate defensive checks.The code correctly identifies and addresses a real tracking gap: promotion/demotion inputs are locked via
m_wallet->LockCoin()inJoinExistingQueue/StartNewQueuebut are not added tovecOutPointLockeduntilPreparePromotionEntry/PrepareDemotionEntry. Early session failures (connection issues, timeouts, validation failures) that callSetNull()before these preparation methods would leave inputs locked in the wallet but missing fromvecOutPointLocked. The SetNull() code transfersm_vecPromotionInputstovecOutPointLockedbefore clearing state, ensuringUnlockCoins()will properly unlock them. The duplicate check is necessary and defensive—it prevents adding the same outpoint multiple times tovecOutPointLocked, which could occur if the code path executes multiple times or encounters edge cases where inputs exist in both sources. This implementation is sound.
|
This pull request has conflicts, please rebase. |
d140732 to
bc4b034
Compare
Fixes for PR dashpay#7052: Issue #1 (CRITICAL): Fix race condition in coin selection - Lock coins immediately after selection in JoinExistingQueue/StartNewQueue - Prevents concurrent sessions from selecting the same coins - Add defensive IsLockedCoin check in Prepare functions Issue #2 (CRITICAL): Fix resource leak on session failure - Add promotion inputs to vecOutPointLocked in SetNull() - Ensures coins locked early are properly unlocked if session fails - Leverages existing UnlockCoins() retry mechanism Issue #3: Add UTXO validation before use - Check IsSpent() before using promotion/demotion inputs - Prevents using externally spent or transferred coins 🤖 Generated with [Claude Code](https://claude.com/claude-code)
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/coinjoin/coinjoin.cpp (1)
219-360:⚠️ Potential issue | 🟠 MajorThis validator now accepts non-uniform CoinJoin entries.
The new
PROMOTION/DEMOTIONbranches explicitly accept 10:1 and 1:10 shapes, so CoinJoin validation no longer enforces uniform denomination outputs inside a session. If this protocol change is intentional, the repository rule needs to be updated alongside it.As per coding guidelines,
src/coinjoin/**/*.{cpp,h}: CoinJoin implementation must use masternode-coordinated mixing sessions with uniform denomination outputs.Also applies to: 586-677
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/coinjoin/coinjoin.cpp` around lines 219 - 360, The validator now permits non-uniform PROMOTION/DEMOTION entries (10:1 and 1:10) which breaks the rule that CoinJoin sessions must have uniform denomination outputs; revert this by disabling the PROMOTION/DEMOTION branches so only STANDARD entries are allowed: change the EntryType detection (symbols: EntryType, fV24Active, CoinJoin::PROMOTION_RATIO) to treat any inputs/outputs size mismatch as an error (preserve the existing pre-V24 error path), and ensure checkTxOut and denomination expectations always use nSessionDenom (symbol: nSessionDenom, checkTxOut) so all inputs/outputs are validated against the session denom only.
♻️ Duplicate comments (2)
src/coinjoin/client.cpp (2)
1208-1214:⚠️ Potential issue | 🟠 MajorBounds-check
outpoint.nbefore indexingvout.Both loops trust
outpoint.nand readwtx.tx->vout[outpoint.n]unchecked. A stale or inconsistent outpoint here becomes undefined behaviour instead of a logged skip.Also applies to: 1410-1416
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/coinjoin/client.cpp` around lines 1208 - 1214, The code indexes wtx.tx->vout with outpoint.n without bounds checking which can cause UB for stale/inconsistent outpoints; before constructing CTxDSIn in the loop over vecCoins (and the similar loop around the CTxDSIn creation at the 1410-1416 region), verify that outpoint.n is less than wtx.tx->vout.size() and if not, log a warning (including the outpoint/hash and n) and continue to the next outpoint instead of indexing; apply the same guard around every use of wtx.tx->vout[outpoint.n] (e.g., where CTxDSIn is built) to prevent unsafe access.
1221-1225:⚠️ Potential issue | 🔴 CriticalEarly exits still leak locked promotion/demotion coins.
These
LockCoin()calls happen before the later winner/rate-limit/connection rejection branches, butm_vecPromotionInputsis not populated until Line 1279 / Line 1500 and nothing adds these outpoints tovecOutPointLockedon the early exits. The newSetNull()fallback therefore cannot recover them, so a failed attempt can leave user funds locked indefinitely.Also applies to: 1247-1251, 1262-1265, 1423-1426, 1444-1448, 1461-1488, 1515-1516
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/coinjoin/client.cpp` around lines 1221 - 1225, The loop that calls m_wallet->LockCoin(outpoint) on vecCoins can leak locks on early exits because vecOutPointLocked isn't populated and SetNull() can't recover them; either move the locking until after winner/rate-limit/connection checks (so promotions in m_vecPromotionInputs are set) or, simpler, immediately record each locked outpoint by pushing it into vecOutPointLocked inside the same loop (for each outpoint in vecCoins) so any subsequent early-exit cleanup (SetNull()/unlock logic) can find and release them; update all analogous locking sites (the other ranges mentioned) to follow the same pattern.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/coinjoin/client.cpp`:
- Around line 296-311: The fallback only appends m_vecPromotionInputs into
vecOutPointLocked but never unlocks them, leaving some coins locked
indefinitely; call UnlockCoins(...) on the promotion inputs before
clearing/resetting to ensure they are released. Specifically, in the same
cleanup path that touches m_vecPromotionInputs and flags (m_fPromotion,
m_fDemotion), invoke UnlockCoins(m_vecPromotionInputs) (or UnlockCoins with the
appropriate overload) so those outpoints are unlocked even if they were never
added earlier to vecOutPointLocked; keep this change consistent with other
callers like ResetPool(), CompletedTransaction(), and the POOL_STATE_ERROR
timeout branch that expect UnlockCoins() to run before SetNull()/clearing state.
In `@src/coinjoin/coinjoin.h`:
- Around line 296-297: Change the IsExpired method signature to accept the
correct Chainlock handler type: replace the parameter type
llmq::CChainLocksHandler with chainlock::ChainlockHandler in the IsExpired
declaration and any corresponding definition; if chainlock::ChainlockHandler
isn't visible here, add or update a forward declaration for class
ChainlockHandler inside the chainlock namespace (or include the appropriate
header) so the compiler can resolve chainlock::ChainlockHandler when compiling
the IsExpired(const CBlockIndex* pindex, const chainlock::ChainlockHandler&
clhandler) method.
In `@src/coinjoin/server.cpp`:
- Around line 231-233: The tip lookups calling m_chainman.ActiveChain().Tip()
and the subsequent DeploymentActiveAt check (using pindex,
Params().GetConsensus(), Consensus::DEPLOYMENT_V24) must be performed while
holding the chainstate lock to avoid races; wrap the code that obtains pindex
and evaluates fV24Active with LOCK(::cs_main) (the same pattern used in
CCoinJoinBaseSession::IsValidInOuts()) so the activation gate cannot change
mid-request. Apply the same LOCK(::cs_main) guarding to the analogous checks
around lines 620-622 as well.
- Around line 304-307: The finalize path that calls CreateFinalTransaction()
when all accepted participants have submitted must also enforce the minimum
standard-mixer count; update the branch that transitions from
POOL_STATE_ACCEPTING_ENTRIES to the finalize step (the code that currently calls
CreateFinalTransaction() when all submissions are present) to include the same
guard used in the timeout path: check GetStandardEntriesCount() >=
CoinJoin::GetMinPoolParticipants() (and CCoinJoinServer::HasTimedOut() logic as
appropriate) before invoking CreateFinalTransaction(), and abort/skip
finalization if the condition is not met so promotion/demotion-only sessions
cannot complete.
---
Outside diff comments:
In `@src/coinjoin/coinjoin.cpp`:
- Around line 219-360: The validator now permits non-uniform PROMOTION/DEMOTION
entries (10:1 and 1:10) which breaks the rule that CoinJoin sessions must have
uniform denomination outputs; revert this by disabling the PROMOTION/DEMOTION
branches so only STANDARD entries are allowed: change the EntryType detection
(symbols: EntryType, fV24Active, CoinJoin::PROMOTION_RATIO) to treat any
inputs/outputs size mismatch as an error (preserve the existing pre-V24 error
path), and ensure checkTxOut and denomination expectations always use
nSessionDenom (symbol: nSessionDenom, checkTxOut) so all inputs/outputs are
validated against the session denom only.
---
Duplicate comments:
In `@src/coinjoin/client.cpp`:
- Around line 1208-1214: The code indexes wtx.tx->vout with outpoint.n without
bounds checking which can cause UB for stale/inconsistent outpoints; before
constructing CTxDSIn in the loop over vecCoins (and the similar loop around the
CTxDSIn creation at the 1410-1416 region), verify that outpoint.n is less than
wtx.tx->vout.size() and if not, log a warning (including the outpoint/hash and
n) and continue to the next outpoint instead of indexing; apply the same guard
around every use of wtx.tx->vout[outpoint.n] (e.g., where CTxDSIn is built) to
prevent unsafe access.
- Around line 1221-1225: The loop that calls m_wallet->LockCoin(outpoint) on
vecCoins can leak locks on early exits because vecOutPointLocked isn't populated
and SetNull() can't recover them; either move the locking until after
winner/rate-limit/connection checks (so promotions in m_vecPromotionInputs are
set) or, simpler, immediately record each locked outpoint by pushing it into
vecOutPointLocked inside the same loop (for each outpoint in vecCoins) so any
subsequent early-exit cleanup (SetNull()/unlock logic) can find and release
them; update all analogous locking sites (the other ranges mentioned) to follow
the same pattern.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 1f8ea791-13b5-43f4-875c-8f47542e04b0
📥 Commits
Reviewing files that changed from the base of the PR and between d140732ef87897c32385398fa7dca34fa4784091 and bc4b034.
📒 Files selected for processing (6)
src/coinjoin/client.cppsrc/coinjoin/client.hsrc/coinjoin/coinjoin.cppsrc/coinjoin/coinjoin.hsrc/coinjoin/common.hsrc/coinjoin/server.cpp
| [[nodiscard]] bool IsExpired(const CBlockIndex* pindex, const llmq::CChainLocksHandler& clhandler) const; | ||
| [[nodiscard]] bool IsValidStructure(const CBlockIndex* pindex) const; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '34,40p' src/coinjoin/coinjoin.h
sed -n '292,297p' src/coinjoin/coinjoin.h
sed -n '420,445p' src/coinjoin/coinjoin.h
rg -n -C2 '\b(CChainLocksHandler|Chainlocks)\b' srcRepository: dashpay/dash
Length of output: 33868
Fix incorrect type name in IsExpired() signature.
Line 296 declares IsExpired with parameter const llmq::CChainLocksHandler& clhandler, but CChainLocksHandler does not exist in the llmq namespace. The correct type is chainlock::ChainlockHandler. Update the signature to use the correct type and add the necessary forward declaration if not already present.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/coinjoin/coinjoin.h` around lines 296 - 297, Change the IsExpired method
signature to accept the correct Chainlock handler type: replace the parameter
type llmq::CChainLocksHandler with chainlock::ChainlockHandler in the IsExpired
declaration and any corresponding definition; if chainlock::ChainlockHandler
isn't visible here, add or update a forward declaration for class
ChainlockHandler inside the chainlock namespace (or include the appropriate
header) so the compiler can resolve chainlock::ChainlockHandler when compiling
the IsExpired(const CBlockIndex* pindex, const chainlock::ChainlockHandler&
clhandler) method.
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
PR #7052 adds CoinJoin promotion (10 smaller → 1 larger) and demotion (1 larger → 10 smaller) gated by V24 activation. The core validation logic in IsValidInOuts is sound. Two blocking issues remain: (1) the full-session finalization path bypasses the new minimum standard mixer requirement, allowing sessions with zero privacy cover; (2) the new coin selection function skips the IsLockedCoin check, enabling concurrent sessions to double-spend the same inputs.
Reviewed commit: c2089b6
🔴 2 blocking | 🟡 3 suggestion(s) | 💬 1 nitpick(s)
1 additional finding
🔴 blocking: Full-session finalization bypasses minimum standard mixer check
src/coinjoin/server.cpp (lines 296-299)
CheckPool() has two finalization paths. The timeout path (line 306-307) correctly enforces GetStandardEntriesCount() >= CoinJoin::GetMinPoolParticipants(). But the full-session path (line 296) only checks GetEntriesCount() == vecSessionCollaterals.size() — it does not verify that enough standard (1:1) entries exist. If all participants submit promotion/demotion entries, the session finalizes with zero standard mixers, providing no privacy cover.
Both review agents independently flagged this as the highest-priority issue.
💡 Suggested change
if (nState == POOL_STATE_ACCEPTING_ENTRIES && size_t(GetEntriesCount()) == vecSessionCollaterals.size()) {
if (GetStandardEntriesCount() >= CoinJoin::GetMinPoolParticipants()) {
LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- FINALIZE TRANSACTIONS\n");
CreateFinalTransaction();
return;
}
LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- all entries received but insufficient standard mixers (%d), waiting for timeout\n", GetStandardEntriesCount());
}
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `src/coinjoin/server.cpp`:
- [BLOCKING] lines 296-299: Full-session finalization bypasses minimum standard mixer check
CheckPool() has two finalization paths. The timeout path (line 306-307) correctly enforces `GetStandardEntriesCount() >= CoinJoin::GetMinPoolParticipants()`. But the full-session path (line 296) only checks `GetEntriesCount() == vecSessionCollaterals.size()` — it does not verify that enough standard (1:1) entries exist. If all participants submit promotion/demotion entries, the session finalizes with zero standard mixers, providing no privacy cover.
Both review agents independently flagged this as the highest-priority issue.
- [SUGGESTION] lines 230-232: V24 activation checks access ActiveChain().Tip() without cs_main
Two locations access `m_chainman.ActiveChain().Tip()` without holding cs_main:
- ProcessDSVIN (line 231): checks V24 activation for unbalanced entry validation
- AddEntry (line 620): checks V24 activation to determine nMaxEntryInputs
The tip pointer could become stale between read and use. The downstream IsValidInOuts() at coinjoin.cpp:222-225 does acquire cs_main properly, creating an inconsistency. These are race windows that could allow a brief period of incorrect accept/reject behavior during V24 activation.
In `src/wallet/coinjoin.cpp`:
- [BLOCKING] lines 443-471: SelectFullyMixedForPromotion ignores wallet coin locks, enabling double-reservation
SelectFullyMixedForPromotion() (line 443-471) iterates setWalletUTXO without checking IsLockedCoin(). The existing denomination selector at line 181 correctly filters with `IsSpent(target_outpoint) || IsLockedCoin(target_outpoint)`. Without this check, concurrent CoinJoin sessions can select and reserve the same UTXOs for promotion, leading to signing failures or double-spend attempts.
CountCoinsByDenomination() (line 414-441) has the same omission but is less critical since it's only used for counting.
In `src/coinjoin/coinjoin.cpp`:
- [SUGGESTION] lines 586-677: ValidatePromotionEntry/ValidateDemotionEntry are dead code in production
ValidatePromotionEntry() and ValidateDemotionEntry() are declared in coinjoin.h, implemented in coinjoin.cpp (lines 586-677), and extensively tested in coinjoin_inouts_tests.cpp. However, they are never called from production code — the actual validation is performed inline within IsValidInOuts(). This creates dead code and a maintenance risk: the tested functions could diverge from the actual validation path without detection.
In `src/test/coinjoin_inouts_tests.cpp`:
- [SUGGESTION] lines 355-386: Post-V24 acceptance paths lack unit test coverage
The test at line 382 confirms pre-V24 rejection of unbalanced entries but explicitly notes (line 384) that post-V24 acceptance requires a CBlockIndex with V24 active, which is not set up. The post-V24 code paths — where promotion and demotion entries are actually accepted and validated — are not exercised in unit tests. This means the core new functionality is only covered by the standalone Validate*Entry tests (which are dead code in production, per finding above).
| std::vector<COutPoint> CWallet::SelectFullyMixedForPromotion(int nDenom, int nCount) const | ||
| { | ||
| std::vector<COutPoint> vecRet; | ||
| if (!CCoinJoinClientOptions::IsEnabled()) return vecRet; | ||
|
|
||
| const CAmount nDenomAmount = CoinJoin::DenominationToAmount(nDenom); | ||
| if (nDenomAmount <= 0) return vecRet; | ||
|
|
||
| LOCK(cs_wallet); | ||
| for (const auto& outpoint : setWalletUTXO) { | ||
| if (static_cast<int>(vecRet.size()) >= nCount) break; | ||
|
|
||
| const auto it{mapWallet.find(outpoint.hash)}; | ||
| if (it == mapWallet.end()) continue; | ||
|
|
||
| const CAmount nValue = it->second.tx->vout[outpoint.n].nValue; | ||
| if (nValue != nDenomAmount) continue; | ||
|
|
||
| // Skip unconfirmed or conflicted | ||
| if (GetTxDepthInMainChain(it->second) < 1) continue; | ||
|
|
||
| // Must be fully mixed for promotion | ||
| if (!IsFullyMixed(outpoint)) continue; | ||
|
|
||
| vecRet.push_back(outpoint); | ||
| } | ||
|
|
||
| return vecRet; | ||
| } |
There was a problem hiding this comment.
🔴 Blocking: SelectFullyMixedForPromotion ignores wallet coin locks, enabling double-reservation
SelectFullyMixedForPromotion() (line 443-471) iterates setWalletUTXO without checking IsLockedCoin(). The existing denomination selector at line 181 correctly filters with IsSpent(target_outpoint) || IsLockedCoin(target_outpoint). Without this check, concurrent CoinJoin sessions can select and reserve the same UTXOs for promotion, leading to signing failures or double-spend attempts.
CountCoinsByDenomination() (line 414-441) has the same omission but is less critical since it's only used for counting.
💡 Suggested change
| std::vector<COutPoint> CWallet::SelectFullyMixedForPromotion(int nDenom, int nCount) const | |
| { | |
| std::vector<COutPoint> vecRet; | |
| if (!CCoinJoinClientOptions::IsEnabled()) return vecRet; | |
| const CAmount nDenomAmount = CoinJoin::DenominationToAmount(nDenom); | |
| if (nDenomAmount <= 0) return vecRet; | |
| LOCK(cs_wallet); | |
| for (const auto& outpoint : setWalletUTXO) { | |
| if (static_cast<int>(vecRet.size()) >= nCount) break; | |
| const auto it{mapWallet.find(outpoint.hash)}; | |
| if (it == mapWallet.end()) continue; | |
| const CAmount nValue = it->second.tx->vout[outpoint.n].nValue; | |
| if (nValue != nDenomAmount) continue; | |
| // Skip unconfirmed or conflicted | |
| if (GetTxDepthInMainChain(it->second) < 1) continue; | |
| // Must be fully mixed for promotion | |
| if (!IsFullyMixed(outpoint)) continue; | |
| vecRet.push_back(outpoint); | |
| } | |
| return vecRet; | |
| } | |
| Add `if (IsLockedCoin(outpoint)) continue;` after the `if (it == mapWallet.end()) continue;` check in SelectFullyMixedForPromotion(). Consider adding the same to CountCoinsByDenomination() for consistency. |
source: ['codex']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `src/wallet/coinjoin.cpp`:
- [BLOCKING] lines 443-471: SelectFullyMixedForPromotion ignores wallet coin locks, enabling double-reservation
SelectFullyMixedForPromotion() (line 443-471) iterates setWalletUTXO without checking IsLockedCoin(). The existing denomination selector at line 181 correctly filters with `IsSpent(target_outpoint) || IsLockedCoin(target_outpoint)`. Without this check, concurrent CoinJoin sessions can select and reserve the same UTXOs for promotion, leading to signing failures or double-spend attempts.
CountCoinsByDenomination() (line 414-441) has the same omission but is less critical since it's only used for counting.
| // This is a promotion or demotion entry - requires V24 activation | ||
| const CBlockIndex* pindex = m_chainman.ActiveChain().Tip(); | ||
| const bool fV24Active = pindex && DeploymentActiveAt(*pindex, Params().GetConsensus(), Consensus::DEPLOYMENT_V24); |
There was a problem hiding this comment.
🟡 Suggestion: V24 activation checks access ActiveChain().Tip() without cs_main
Two locations access m_chainman.ActiveChain().Tip() without holding cs_main:
- ProcessDSVIN (line 231): checks V24 activation for unbalanced entry validation
- AddEntry (line 620): checks V24 activation to determine nMaxEntryInputs
The tip pointer could become stale between read and use. The downstream IsValidInOuts() at coinjoin.cpp:222-225 does acquire cs_main properly, creating an inconsistency. These are race windows that could allow a brief period of incorrect accept/reject behavior during V24 activation.
💡 Suggested change
| // This is a promotion or demotion entry - requires V24 activation | |
| const CBlockIndex* pindex = m_chainman.ActiveChain().Tip(); | |
| const bool fV24Active = pindex && DeploymentActiveAt(*pindex, Params().GetConsensus(), Consensus::DEPLOYMENT_V24); | |
| Wrap both V24 checks in `LOCK(cs_main)` or hoist the activation check into a helper that acquires the lock. |
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `src/coinjoin/server.cpp`:
- [SUGGESTION] lines 230-232: V24 activation checks access ActiveChain().Tip() without cs_main
Two locations access `m_chainman.ActiveChain().Tip()` without holding cs_main:
- ProcessDSVIN (line 231): checks V24 activation for unbalanced entry validation
- AddEntry (line 620): checks V24 activation to determine nMaxEntryInputs
The tip pointer could become stale between read and use. The downstream IsValidInOuts() at coinjoin.cpp:222-225 does acquire cs_main properly, creating an inconsistency. These are race windows that could allow a brief period of incorrect accept/reject behavior during V24 activation.
| bool CoinJoin::ValidatePromotionEntry(const std::vector<CTxIn>& vecTxIn, const std::vector<CTxOut>& vecTxOut, | ||
| int nSessionDenom, PoolMessage& nMessageIDRet) | ||
| { | ||
| // Promotion: 10 inputs of smaller denom → 1 output of larger denom | ||
| // Session denom is the smaller denom (inputs) | ||
| nMessageIDRet = MSG_NOERR; | ||
|
|
||
| // Check input count | ||
| if (vecTxIn.size() != static_cast<size_t>(PROMOTION_RATIO)) { | ||
| LogPrint(BCLog::COINJOIN, "CoinJoin::ValidatePromotionEntry -- ERROR: wrong input count %zu, expected %d\n", | ||
| vecTxIn.size(), PROMOTION_RATIO); | ||
| nMessageIDRet = ERR_SIZE_MISMATCH; | ||
| return false; | ||
| } | ||
|
|
||
| // Check output count | ||
| if (vecTxOut.size() != 1) { | ||
| LogPrint(BCLog::COINJOIN, "CoinJoin::ValidatePromotionEntry -- ERROR: wrong output count %zu, expected 1\n", | ||
| vecTxOut.size()); | ||
| nMessageIDRet = ERR_SIZE_MISMATCH; | ||
| return false; | ||
| } | ||
|
|
||
| // Get the larger adjacent denomination | ||
| const int nLargerDenom = GetLargerAdjacentDenom(nSessionDenom); | ||
| if (nLargerDenom == 0) { | ||
| LogPrint(BCLog::COINJOIN, "CoinJoin::ValidatePromotionEntry -- ERROR: no larger adjacent denom for %s\n", | ||
| DenominationToString(nSessionDenom)); | ||
| nMessageIDRet = ERR_DENOM; | ||
| return false; | ||
| } | ||
|
|
||
| // Validate output is at larger denomination | ||
| const int nOutputDenom = AmountToDenomination(vecTxOut[0].nValue); | ||
| if (nOutputDenom != nLargerDenom) { | ||
| LogPrint(BCLog::COINJOIN, "CoinJoin::ValidatePromotionEntry -- ERROR: output denom %s != expected %s\n", | ||
| DenominationToString(nOutputDenom), DenominationToString(nLargerDenom)); | ||
| nMessageIDRet = ERR_DENOM; | ||
| return false; | ||
| } | ||
|
|
||
| // Validate output is P2PKH | ||
| if (!vecTxOut[0].scriptPubKey.IsPayToPublicKeyHash()) { | ||
| LogPrint(BCLog::COINJOIN, "CoinJoin::ValidatePromotionEntry -- ERROR: output is not P2PKH\n"); | ||
| nMessageIDRet = ERR_INVALID_SCRIPT; | ||
| return false; | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| bool CoinJoin::ValidateDemotionEntry(const std::vector<CTxIn>& vecTxIn, const std::vector<CTxOut>& vecTxOut, | ||
| int nSessionDenom, PoolMessage& nMessageIDRet) | ||
| { | ||
| // Demotion: 1 input of larger denom → 10 outputs of smaller denom | ||
| // Session denom is the smaller denom (outputs) | ||
| nMessageIDRet = MSG_NOERR; | ||
|
|
||
| // Check input count | ||
| if (vecTxIn.size() != 1) { | ||
| LogPrint(BCLog::COINJOIN, "CoinJoin::ValidateDemotionEntry -- ERROR: wrong input count %zu, expected 1\n", | ||
| vecTxIn.size()); | ||
| nMessageIDRet = ERR_SIZE_MISMATCH; | ||
| return false; | ||
| } | ||
|
|
||
| // Check output count | ||
| if (vecTxOut.size() != static_cast<size_t>(PROMOTION_RATIO)) { | ||
| LogPrint(BCLog::COINJOIN, "CoinJoin::ValidateDemotionEntry -- ERROR: wrong output count %zu, expected %d\n", | ||
| vecTxOut.size(), PROMOTION_RATIO); | ||
| nMessageIDRet = ERR_SIZE_MISMATCH; | ||
| return false; | ||
| } | ||
|
|
||
| // Validate all outputs are at session denomination and P2PKH | ||
| for (const auto& txout : vecTxOut) { | ||
| const int nDenom = AmountToDenomination(txout.nValue); | ||
| if (nDenom != nSessionDenom) { | ||
| LogPrint(BCLog::COINJOIN, "CoinJoin::ValidateDemotionEntry -- ERROR: output denom %s != session denom %s\n", | ||
| DenominationToString(nDenom), DenominationToString(nSessionDenom)); | ||
| nMessageIDRet = ERR_DENOM; | ||
| return false; | ||
| } | ||
| if (!txout.scriptPubKey.IsPayToPublicKeyHash()) { | ||
| LogPrint(BCLog::COINJOIN, "CoinJoin::ValidateDemotionEntry -- ERROR: output is not P2PKH\n"); | ||
| nMessageIDRet = ERR_INVALID_SCRIPT; | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| return true; | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: ValidatePromotionEntry/ValidateDemotionEntry are dead code in production
ValidatePromotionEntry() and ValidateDemotionEntry() are declared in coinjoin.h, implemented in coinjoin.cpp (lines 586-677), and extensively tested in coinjoin_inouts_tests.cpp. However, they are never called from production code — the actual validation is performed inline within IsValidInOuts(). This creates dead code and a maintenance risk: the tested functions could diverge from the actual validation path without detection.
💡 Suggested change
| bool CoinJoin::ValidatePromotionEntry(const std::vector<CTxIn>& vecTxIn, const std::vector<CTxOut>& vecTxOut, | |
| int nSessionDenom, PoolMessage& nMessageIDRet) | |
| { | |
| // Promotion: 10 inputs of smaller denom → 1 output of larger denom | |
| // Session denom is the smaller denom (inputs) | |
| nMessageIDRet = MSG_NOERR; | |
| // Check input count | |
| if (vecTxIn.size() != static_cast<size_t>(PROMOTION_RATIO)) { | |
| LogPrint(BCLog::COINJOIN, "CoinJoin::ValidatePromotionEntry -- ERROR: wrong input count %zu, expected %d\n", | |
| vecTxIn.size(), PROMOTION_RATIO); | |
| nMessageIDRet = ERR_SIZE_MISMATCH; | |
| return false; | |
| } | |
| // Check output count | |
| if (vecTxOut.size() != 1) { | |
| LogPrint(BCLog::COINJOIN, "CoinJoin::ValidatePromotionEntry -- ERROR: wrong output count %zu, expected 1\n", | |
| vecTxOut.size()); | |
| nMessageIDRet = ERR_SIZE_MISMATCH; | |
| return false; | |
| } | |
| // Get the larger adjacent denomination | |
| const int nLargerDenom = GetLargerAdjacentDenom(nSessionDenom); | |
| if (nLargerDenom == 0) { | |
| LogPrint(BCLog::COINJOIN, "CoinJoin::ValidatePromotionEntry -- ERROR: no larger adjacent denom for %s\n", | |
| DenominationToString(nSessionDenom)); | |
| nMessageIDRet = ERR_DENOM; | |
| return false; | |
| } | |
| // Validate output is at larger denomination | |
| const int nOutputDenom = AmountToDenomination(vecTxOut[0].nValue); | |
| if (nOutputDenom != nLargerDenom) { | |
| LogPrint(BCLog::COINJOIN, "CoinJoin::ValidatePromotionEntry -- ERROR: output denom %s != expected %s\n", | |
| DenominationToString(nOutputDenom), DenominationToString(nLargerDenom)); | |
| nMessageIDRet = ERR_DENOM; | |
| return false; | |
| } | |
| // Validate output is P2PKH | |
| if (!vecTxOut[0].scriptPubKey.IsPayToPublicKeyHash()) { | |
| LogPrint(BCLog::COINJOIN, "CoinJoin::ValidatePromotionEntry -- ERROR: output is not P2PKH\n"); | |
| nMessageIDRet = ERR_INVALID_SCRIPT; | |
| return false; | |
| } | |
| return true; | |
| } | |
| bool CoinJoin::ValidateDemotionEntry(const std::vector<CTxIn>& vecTxIn, const std::vector<CTxOut>& vecTxOut, | |
| int nSessionDenom, PoolMessage& nMessageIDRet) | |
| { | |
| // Demotion: 1 input of larger denom → 10 outputs of smaller denom | |
| // Session denom is the smaller denom (outputs) | |
| nMessageIDRet = MSG_NOERR; | |
| // Check input count | |
| if (vecTxIn.size() != 1) { | |
| LogPrint(BCLog::COINJOIN, "CoinJoin::ValidateDemotionEntry -- ERROR: wrong input count %zu, expected 1\n", | |
| vecTxIn.size()); | |
| nMessageIDRet = ERR_SIZE_MISMATCH; | |
| return false; | |
| } | |
| // Check output count | |
| if (vecTxOut.size() != static_cast<size_t>(PROMOTION_RATIO)) { | |
| LogPrint(BCLog::COINJOIN, "CoinJoin::ValidateDemotionEntry -- ERROR: wrong output count %zu, expected %d\n", | |
| vecTxOut.size(), PROMOTION_RATIO); | |
| nMessageIDRet = ERR_SIZE_MISMATCH; | |
| return false; | |
| } | |
| // Validate all outputs are at session denomination and P2PKH | |
| for (const auto& txout : vecTxOut) { | |
| const int nDenom = AmountToDenomination(txout.nValue); | |
| if (nDenom != nSessionDenom) { | |
| LogPrint(BCLog::COINJOIN, "CoinJoin::ValidateDemotionEntry -- ERROR: output denom %s != session denom %s\n", | |
| DenominationToString(nDenom), DenominationToString(nSessionDenom)); | |
| nMessageIDRet = ERR_DENOM; | |
| return false; | |
| } | |
| if (!txout.scriptPubKey.IsPayToPublicKeyHash()) { | |
| LogPrint(BCLog::COINJOIN, "CoinJoin::ValidateDemotionEntry -- ERROR: output is not P2PKH\n"); | |
| nMessageIDRet = ERR_INVALID_SCRIPT; | |
| return false; | |
| } | |
| } | |
| return true; | |
| } | |
| Either refactor IsValidInOuts() to delegate to these functions, or remove them and test IsValidInOuts() directly. |
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `src/coinjoin/coinjoin.cpp`:
- [SUGGESTION] lines 586-677: ValidatePromotionEntry/ValidateDemotionEntry are dead code in production
ValidatePromotionEntry() and ValidateDemotionEntry() are declared in coinjoin.h, implemented in coinjoin.cpp (lines 586-677), and extensively tested in coinjoin_inouts_tests.cpp. However, they are never called from production code — the actual validation is performed inline within IsValidInOuts(). This creates dead code and a maintenance risk: the tested functions could diverge from the actual validation path without detection.
| BOOST_AUTO_TEST_CASE(isvalidstructure_postfork_unbalanced_valid) | ||
| { | ||
| // Post-V24: Unbalanced vin/vout (promotion: 10 inputs, 1 output) should be valid | ||
| // We need a mock pindex that signals V24 active - for this test we use nullptr which means pre-fork | ||
| // This test validates that the structure check correctly identifies promotion structure | ||
|
|
||
| CCoinJoinBroadcastTx promo; | ||
| { | ||
| CMutableTransaction mtx; | ||
| // Promotion: 10 inputs of smaller denom -> 1 output of larger denom | ||
| const int nInputCount = CoinJoin::PROMOTION_RATIO; | ||
| const CAmount nLargerAmount = CoinJoin::DenominationToAmount(1 << 1); // 1.0 DASH | ||
|
|
||
| for (int i = 0; i < nInputCount; ++i) { | ||
| CTxIn in; | ||
| in.prevout = COutPoint(uint256::ONE, static_cast<uint32_t>(i)); | ||
| mtx.vin.push_back(in); | ||
| } | ||
| // 1 output of larger denom | ||
| CTxOut out{nLargerAmount, P2PKHScript(0x01)}; | ||
| mtx.vout.push_back(out); | ||
|
|
||
| promo.tx = MakeTransactionRef(mtx); | ||
| promo.m_protxHash = uint256::ONE; | ||
| } | ||
|
|
||
| // Pre-V24 (nullptr): unbalanced should fail | ||
| BOOST_CHECK(!promo.IsValidStructure(nullptr)); | ||
|
|
||
| // Note: Post-V24 test would require a valid CBlockIndex with V24 deployment active | ||
| // which requires more setup. The above confirms pre-fork rejection works. | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Post-V24 acceptance paths lack unit test coverage
The test at line 382 confirms pre-V24 rejection of unbalanced entries but explicitly notes (line 384) that post-V24 acceptance requires a CBlockIndex with V24 active, which is not set up. The post-V24 code paths — where promotion and demotion entries are actually accepted and validated — are not exercised in unit tests. This means the core new functionality is only covered by the standalone Validate*Entry tests (which are dead code in production, per finding above).
💡 Suggested change
| BOOST_AUTO_TEST_CASE(isvalidstructure_postfork_unbalanced_valid) | |
| { | |
| // Post-V24: Unbalanced vin/vout (promotion: 10 inputs, 1 output) should be valid | |
| // We need a mock pindex that signals V24 active - for this test we use nullptr which means pre-fork | |
| // This test validates that the structure check correctly identifies promotion structure | |
| CCoinJoinBroadcastTx promo; | |
| { | |
| CMutableTransaction mtx; | |
| // Promotion: 10 inputs of smaller denom -> 1 output of larger denom | |
| const int nInputCount = CoinJoin::PROMOTION_RATIO; | |
| const CAmount nLargerAmount = CoinJoin::DenominationToAmount(1 << 1); // 1.0 DASH | |
| for (int i = 0; i < nInputCount; ++i) { | |
| CTxIn in; | |
| in.prevout = COutPoint(uint256::ONE, static_cast<uint32_t>(i)); | |
| mtx.vin.push_back(in); | |
| } | |
| // 1 output of larger denom | |
| CTxOut out{nLargerAmount, P2PKHScript(0x01)}; | |
| mtx.vout.push_back(out); | |
| promo.tx = MakeTransactionRef(mtx); | |
| promo.m_protxHash = uint256::ONE; | |
| } | |
| // Pre-V24 (nullptr): unbalanced should fail | |
| BOOST_CHECK(!promo.IsValidStructure(nullptr)); | |
| // Note: Post-V24 test would require a valid CBlockIndex with V24 deployment active | |
| // which requires more setup. The above confirms pre-fork rejection works. | |
| } | |
| Add test fixtures that construct a CBlockIndex with V24 deployment active to exercise the acceptance paths in IsValidStructure() and IsValidInOuts(). |
source: ['claude']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `src/test/coinjoin_inouts_tests.cpp`:
- [SUGGESTION] lines 355-386: Post-V24 acceptance paths lack unit test coverage
The test at line 382 confirms pre-V24 rejection of unbalanced entries but explicitly notes (line 384) that post-V24 acceptance requires a CBlockIndex with V24 active, which is not set up. The post-V24 code paths — where promotion and demotion entries are actually accepted and validated — are not exercised in unit tests. This means the core new functionality is only covered by the standalone Validate*Entry tests (which are dead code in production, per finding above).
| // Post-V24: Promotion/demotion session state | ||
| bool m_fPromotion{false}; // True if this session is promoting smaller -> larger denom | ||
| bool m_fDemotion{false}; // True if this session is demoting larger -> smaller denom | ||
| std::vector<COutPoint> m_vecPromotionInputs; // Selected inputs for promotion (10 coins) |
There was a problem hiding this comment.
💬 Nitpick: m_vecPromotionInputs is reused for demotion inputs, naming is misleading
m_vecPromotionInputs stores inputs for both promotion (10 coins) and demotion (1 coin), as seen at client.cpp:1290-1292 and 1825-1839. The name and comment ("Selected inputs for promotion (10 coins)") imply promotion-only usage. Consider renaming to m_vecMixingInputs or m_vecSessionInputs.
source: ['claude']
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (3)
src/coinjoin/client.cpp (3)
1225-1255:⚠️ Potential issue | 🔴 CriticalDelay rebalance locking until the queue join is definitely kept.
Promotion/demotion inputs are locked before the
IsMasternodeOrDisconnectRequested()continuepath and before the successful session state is recorded. If that check trips, the outpoints are neither invecOutPointLockednor inm_vecRebalanceInputs, so they leak out of cleanup entirely.Also applies to: 1266-1269
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/coinjoin/client.cpp` around lines 1225 - 1255, The code currently locks promotion/demotion inputs (m_wallet->LockCoin calls for vecCoins and vecTxDSInTmp) before the IsMasternodeOrDisconnectRequested() continuation and before recording the session state, causing leaked locked outpoints if the join is aborted; move the LockCoin calls so they occur only after the join is definitively kept (i.e., after the IsMasternodeOrDisconnectRequested() check passes and after you have pushed the outpoints into vecOutPointLocked and/or m_vecRebalanceInputs), and wrap the LockCoin calls with LOCK(m_wallet->cs_wallet) as done elsewhere; apply the same change for the demotion path (the block that currently locks vecTxDSInTmp[0].prevout) and the equivalent locking near the later duplicate section so locks are only taken once the session state/ownership vectors are updated.
296-311:⚠️ Potential issue | 🟠 Major
SetNull()does not actually release rebalance locks.Most cleanup paths already call
UnlockCoins()beforeSetNull()(for example Lines 273, 449, and 728). Appendingm_vecRebalanceInputshere only repopulatesvecOutPointLockedafter the unlock pass, so sessions that fail beforePreparePromotionEntry()orPrepareDemotionEntry()can leave those coins locked indefinitely.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/coinjoin/client.cpp` around lines 296 - 311, SetNull() currently appends m_vecRebalanceInputs to vecOutPointLocked after other UnlockCoins() calls, which re-locks coins; instead ensure rebalance inputs are explicitly unlocked before clearing: call UnlockCoins(m_vecRebalanceInputs) (or add them to vecOutPointLocked before the existing UnlockCoins() pass) and only then clear m_vecRebalanceInputs and reset m_fPromotion/m_fDemotion; reference SetNull(), UnlockCoins(), m_vecRebalanceInputs, vecOutPointLocked, PreparePromotionEntry(), and PrepareDemotionEntry() when making the change.
1431-1456:⚠️ Potential issue | 🔴 Critical
StartNewQueue()has the same pre-selection lock leak.These coins are locked before the masternode retry loop starts, but
m_vecRebalanceInputsis only populated on the success path at Lines 1508-1514. Anycontinueorreturn falsebefore then leaves the rebalance inputs locked with no owner to unlock them.Also applies to: 1467-1514
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/coinjoin/client.cpp` around lines 1431 - 1456, StartNewQueue() locks demotion inputs (m_wallet->LockCoin(vecTxDSInTmp[0].prevout)) before the masternode retry/selection path completes, but m_vecRebalanceInputs is only populated on the success path, causing locked coins to be leaked on early returns; fix by deferring the LockCoin call until after the masternode selection succeeds and m_vecRebalanceInputs will be set, or immediately add the locked prevout into m_vecRebalanceInputs (so ownership/cleanup is tracked) and ensure any early return paths unlock those entries; update the CCoinJoinClientSession::StartNewQueue logic around vecTxDSInTmp/prevout locking to either move the LOCK(m_wallet->cs_wallet)/LockCoin(...) to after the success branch or push_back the prevout into m_vecRebalanceInputs right when locking so cleanup/unlock is guaranteed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/coinjoin/coinjoin.cpp`:
- Around line 227-276: IsValidInOuts() now treats the whole vin/vout as one
EntryType (EntryType enum) and rejects mixed-session final transactions (e.g.
multiple STANDARD entries plus a PROMOTION), causing clients/servers (client.cpp
and server.cpp callers) to fail signing; fix by restoring per-entry validation
or adding a dedicated final-transaction validator: either (A) change the callers
in client.cpp/server.cpp to call the existing per-entry validators
(CoinJoin::ValidatePromotionEntry, CoinJoin::ValidateDemotionEntry, or the
STANDARD path) for each logical entry instead of passing the whole tx into
IsValidInOuts(), or (B) implement a new function (e.g.,
CoinJoin::ValidateFinalTransaction) that parses the combined vin/vout into
constituent entries, validates each entry with the existing
ValidatePromotionEntry/ValidateDemotionEntry logic, and determines expected
denominations using CoinJoin::GetLargerAdjacentDenom per entry; update
IsValidInOuts() to only validate single entries or delegate to the new
ValidateFinalTransaction when given a full-final-tx context so mixed entries are
accepted.
- Around line 103-127: Add an explicit post‑V24 cap on outputs: after computing
nMaxInputs (which uses fV24Active, CoinJoin::GetMaxPoolParticipants() and
CoinJoin::PROMOTION_RATIO), add a check that tx->vout.size() <= nMaxInputs and
return false if it exceeds it; keep the existing denominated P2PKH validation
unchanged so IsValidStructure()/the surrounding logic enforces both input and
output caps.
In `@src/wallet/coinjoin.cpp`:
- Around line 424-431: The loop over setWalletUTXO accesses
it->second.tx->vout[outpoint.n] without verifying outpoint.n is a valid index;
add a guard before any vout indexing in both loops (the one around
mapWallet.find and the one at 454-463) to check that it->second.tx is non-null
and outpoint.n < it->second.tx->vout.size(), and continue if not; keep existing
checks (IsSpent, IsLockedCoin, nValue == nDenomAmount) unchanged but perform
them only after this bounds/null check to avoid out-of-bounds reads when
handling stale or inconsistent outpoints (symbols: setWalletUTXO, mapWallet,
it->second.tx, vout, outpoint.n, IsSpent, IsLockedCoin, nDenomAmount).
---
Duplicate comments:
In `@src/coinjoin/client.cpp`:
- Around line 1225-1255: The code currently locks promotion/demotion inputs
(m_wallet->LockCoin calls for vecCoins and vecTxDSInTmp) before the
IsMasternodeOrDisconnectRequested() continuation and before recording the
session state, causing leaked locked outpoints if the join is aborted; move the
LockCoin calls so they occur only after the join is definitively kept (i.e.,
after the IsMasternodeOrDisconnectRequested() check passes and after you have
pushed the outpoints into vecOutPointLocked and/or m_vecRebalanceInputs), and
wrap the LockCoin calls with LOCK(m_wallet->cs_wallet) as done elsewhere; apply
the same change for the demotion path (the block that currently locks
vecTxDSInTmp[0].prevout) and the equivalent locking near the later duplicate
section so locks are only taken once the session state/ownership vectors are
updated.
- Around line 296-311: SetNull() currently appends m_vecRebalanceInputs to
vecOutPointLocked after other UnlockCoins() calls, which re-locks coins; instead
ensure rebalance inputs are explicitly unlocked before clearing: call
UnlockCoins(m_vecRebalanceInputs) (or add them to vecOutPointLocked before the
existing UnlockCoins() pass) and only then clear m_vecRebalanceInputs and reset
m_fPromotion/m_fDemotion; reference SetNull(), UnlockCoins(),
m_vecRebalanceInputs, vecOutPointLocked, PreparePromotionEntry(), and
PrepareDemotionEntry() when making the change.
- Around line 1431-1456: StartNewQueue() locks demotion inputs
(m_wallet->LockCoin(vecTxDSInTmp[0].prevout)) before the masternode
retry/selection path completes, but m_vecRebalanceInputs is only populated on
the success path, causing locked coins to be leaked on early returns; fix by
deferring the LockCoin call until after the masternode selection succeeds and
m_vecRebalanceInputs will be set, or immediately add the locked prevout into
m_vecRebalanceInputs (so ownership/cleanup is tracked) and ensure any early
return paths unlock those entries; update the
CCoinJoinClientSession::StartNewQueue logic around vecTxDSInTmp/prevout locking
to either move the LOCK(m_wallet->cs_wallet)/LockCoin(...) to after the success
branch or push_back the prevout into m_vecRebalanceInputs right when locking so
cleanup/unlock is guaranteed.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: ccc108f2-8300-41e8-b074-26afc4c78c13
📒 Files selected for processing (5)
src/coinjoin/client.cppsrc/coinjoin/client.hsrc/coinjoin/coinjoin.cppsrc/coinjoin/server.cppsrc/wallet/coinjoin.cpp
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
This push addresses 4 of 5 prior review findings cleanly: the blocking coin-lock double-reservation bug is fixed, V24 activation checks now hold cs_main, the dead-code validators are integrated into the production path, and the misleading m_vecPromotionInputs name is corrected to m_vecRebalanceInputs. The new outpoint index bounds checks and IsSpent/IsLockedCoin filters are correct. One design concern: sessions composed entirely of promotion/demotion entries (insufficient standard mixers) will accept all entries then stall until timeout rather than failing fast.
Reviewed commit: 332186c
🟡 2 suggestion(s)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `src/coinjoin/server.cpp`:
- [SUGGESTION] lines 300-307: Sessions with insufficient standard mixers stall until timeout instead of failing fast
Once `GetEntriesCount() == vecSessionCollaterals.size()`, the pool is full — `AddEntry()` rejects further entries and the server is past `POOL_STATE_QUEUE`. If `GetStandardEntriesCount() < GetMinPoolParticipants()` at this point, the session is deterministically unsatisfiable: it will never gain more standard mixers. Lines 300-306 log and wait, then `CheckTimeout()` eventually resets via `SetNull()`. This means all participants (including legitimate standard mixers) wait the full timeout window before the session is recycled.
Consider resetting immediately when the pool is full but the standard-mixer threshold is unachievable, so participants can re-queue without the timeout delay.
In `src/test/coinjoin_inouts_tests.cpp`:
- [SUGGESTION] lines 355-386: Post-V24 acceptance paths still lack integration test coverage
The incremental diff changes live server behavior (IsValidInOuts now dispatches through the dedicated validators; CheckPool enforces minimum standard mixers) but adds no test updates. The standalone `ValidatePromotionEntry`/`ValidateDemotionEntry` tests are now exercising live code (good), but the full server-side path — `ProcessDSVIN` → `AddEntry` → `IsValidInOuts` → `CheckPool` with V24 active — remains untested. The new insufficient-standard-mixer branch is also uncovered.
| if (nState == POOL_STATE_ACCEPTING_ENTRIES && size_t(GetEntriesCount()) == vecSessionCollaterals.size()) { | ||
| LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- FINALIZE TRANSACTIONS\n"); | ||
| CreateFinalTransaction(); | ||
| return; | ||
| if (GetStandardEntriesCount() >= CoinJoin::GetMinPoolParticipants()) { | ||
| LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- FINALIZE TRANSACTIONS\n"); | ||
| CreateFinalTransaction(); | ||
| return; | ||
| } | ||
| LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- all entries received but insufficient standard mixers (%d), waiting for timeout\n", GetStandardEntriesCount()); | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Sessions with insufficient standard mixers stall until timeout instead of failing fast
Once GetEntriesCount() == vecSessionCollaterals.size(), the pool is full — AddEntry() rejects further entries and the server is past POOL_STATE_QUEUE. If GetStandardEntriesCount() < GetMinPoolParticipants() at this point, the session is deterministically unsatisfiable: it will never gain more standard mixers. Lines 300-306 log and wait, then CheckTimeout() eventually resets via SetNull(). This means all participants (including legitimate standard mixers) wait the full timeout window before the session is recycled.
Consider resetting immediately when the pool is full but the standard-mixer threshold is unachievable, so participants can re-queue without the timeout delay.
source: ['codex']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `src/coinjoin/server.cpp`:
- [SUGGESTION] lines 300-307: Sessions with insufficient standard mixers stall until timeout instead of failing fast
Once `GetEntriesCount() == vecSessionCollaterals.size()`, the pool is full — `AddEntry()` rejects further entries and the server is past `POOL_STATE_QUEUE`. If `GetStandardEntriesCount() < GetMinPoolParticipants()` at this point, the session is deterministically unsatisfiable: it will never gain more standard mixers. Lines 300-306 log and wait, then `CheckTimeout()` eventually resets via `SetNull()`. This means all participants (including legitimate standard mixers) wait the full timeout window before the session is recycled.
Consider resetting immediately when the pool is full but the standard-mixer threshold is unachievable, so participants can re-queue without the timeout delay.
| BOOST_AUTO_TEST_CASE(isvalidstructure_postfork_unbalanced_valid) | ||
| { | ||
| // Post-V24: Unbalanced vin/vout (promotion: 10 inputs, 1 output) should be valid | ||
| // We need a mock pindex that signals V24 active - for this test we use nullptr which means pre-fork | ||
| // This test validates that the structure check correctly identifies promotion structure | ||
|
|
||
| CCoinJoinBroadcastTx promo; | ||
| { | ||
| CMutableTransaction mtx; | ||
| // Promotion: 10 inputs of smaller denom -> 1 output of larger denom | ||
| const int nInputCount = CoinJoin::PROMOTION_RATIO; | ||
| const CAmount nLargerAmount = CoinJoin::DenominationToAmount(1 << 1); // 1.0 DASH | ||
|
|
||
| for (int i = 0; i < nInputCount; ++i) { | ||
| CTxIn in; | ||
| in.prevout = COutPoint(uint256::ONE, static_cast<uint32_t>(i)); | ||
| mtx.vin.push_back(in); | ||
| } | ||
| // 1 output of larger denom | ||
| CTxOut out{nLargerAmount, P2PKHScript(0x01)}; | ||
| mtx.vout.push_back(out); | ||
|
|
||
| promo.tx = MakeTransactionRef(mtx); | ||
| promo.m_protxHash = uint256::ONE; | ||
| } | ||
|
|
||
| // Pre-V24 (nullptr): unbalanced should fail | ||
| BOOST_CHECK(!promo.IsValidStructure(nullptr)); | ||
|
|
||
| // Note: Post-V24 test would require a valid CBlockIndex with V24 deployment active | ||
| // which requires more setup. The above confirms pre-fork rejection works. | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Post-V24 acceptance paths still lack integration test coverage
The incremental diff changes live server behavior (IsValidInOuts now dispatches through the dedicated validators; CheckPool enforces minimum standard mixers) but adds no test updates. The standalone ValidatePromotionEntry/ValidateDemotionEntry tests are now exercising live code (good), but the full server-side path — ProcessDSVIN → AddEntry → IsValidInOuts → CheckPool with V24 active — remains untested. The new insufficient-standard-mixer branch is also uncovered.
source: ['claude', 'codex']
🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `src/test/coinjoin_inouts_tests.cpp`:
- [SUGGESTION] lines 355-386: Post-V24 acceptance paths still lack integration test coverage
The incremental diff changes live server behavior (IsValidInOuts now dispatches through the dedicated validators; CheckPool enforces minimum standard mixers) but adds no test updates. The standalone `ValidatePromotionEntry`/`ValidateDemotionEntry` tests are now exercising live code (good), but the full server-side path — `ProcessDSVIN` → `AddEntry` → `IsValidInOuts` → `CheckPool` with V24 active — remains untested. The new insufficient-standard-mixer branch is also uncovered.
|
This pull request has conflicts, please rebase. |
|
⛔ Blockers found — Opus deferred (commit d1fca89) |
…sion AddEntry releases cs_coinjoin around the collateral and UTXO checks, so the scheduler thread can time the session out, build the final transaction and move to POOL_STATE_SIGNING while an entry is still being validated. The pool-state precondition and the IsCurrentSession() re-check that used to close that window were dropped, leaving only a collateral-membership and duplicate-entry test, neither of which notices the transition: vecSessionCollaterals is not pruned on finalization and the straggler has no entry yet. The entry is then appended to vecEntries after finalMutableTransaction was built without it, so its inputs can never be signed, the session stalls until the signing timeout, and ChargeFees consumes the collateral of a participant that was told STATUS_ACCEPTED. Restore the state gate on entry and re-check the session identity before admitting. Route every collateral-consumption path through ConsumeCollateralIfCurrentSession(), which re-verifies under the lock that the session is still live and still holds that collateral, so a session reset racing an in-flight entry no longer spends an uninvolved participant's collateral. Also derive the entry size cap and the declared-shape check from the session's own m_fRebalanceSession instead of re-reading the tip, and pass that capability into IsValidInOuts rather than having it recompute activation. A reorg across the V24 boundary could otherwise shrink the cap under an already-admitted participant and charge it for an entry that was legal when accepted. This drops a redundant cs_main acquisition per entry as well.
…r version m_fRebalanceSession was derived from the session creator's protocol version alone, without consulting whether its dsa actually declared a promotion or demotion. Post-V24, once most wallets are upgraded, nearly every session is opened by a rebalance-capable peer, so nearly every session was flagged rebalance-capable even when every participant was doing ordinary 1:1 mixing. AddUserToExistingSession then answers ERR_VERSION to any peer below COINJOIN_REBALANCE_VERSION, and since CCoinJoinQueue carries no rebalance marker such a peer cannot pre-filter queues: it connects, sends dsa, is rejected, and waits out a ~10s cooldown per attempt. Legacy mixing liquidity collapses to sessions that happen to be opened by other legacy clients. Latch the capability on the first participant that forces the question instead. A session becomes a rebalance session when a rebalance dsa is admitted, and acquires a legacy participant when a pre-rebalance peer is admitted; each side is refused only once the other is actually present. The invariant that matters -- never pair an unbalanced final transaction with a client that cannot validate one -- is preserved, while a 1:1 mixer of either vintage can join any session that has not committed the other way. Also move the HasValidFlags() check above the gating that reads dsa.IsRebalance(), so malformed flags cannot influence what the session latches.
…ld DSTXes Two boundary problems in the DSTX relay path. The reduced PREMATURE penalty for a structurally-post-V24 DSTX applied only when IsPromotionDemotionActive(fNextBlock=true) held, i.e. only when V24 activates in the block immediately after our tip, while IsValidStructure gates on activation at the tip. That is a one-block-wide tolerance. A node two or more blocks behind at the activation height hands the full INVALID penalty to peers relaying perfectly honest promotion DSTXes; ten of them cross DISCOURAGEMENT_THRESHOLD and disconnect an honest peer, and nothing damps the accumulation because DSTX deliberately bypasses the recent-rejects filter. We cannot know how far ahead the sending peer is, so drop the lookahead condition: fPossiblyValidPostV24 already implies we are pre-V24 and the transaction is well-formed under post-V24 rules. PREMATURE still accumulates, so a peer actually flooding us is still discouraged. Separately, when CanAnnounceDstxTo refused a peer the hash was inserted into m_tx_inventory_known_filter with no inventory queued at all, marking the peer as knowing a transaction it would never be offered. Withholding MSG_DSTX from pre-rebalance peers is correct - they would score us for a malformed DSTX - but there is no reason to withhold the transaction itself: ProcessGetData already answers a MSG_TX getdata for a transaction that has a dstx entry, and no mempool policy treats it differently. Announce it as MSG_TX instead, so those peers stop missing rebalance transactions until block inclusion and no longer pay a getblocktxn round-trip for each one during compact-block reconstruction.
is_denominate still required vin.size() == vout.size(), a rule that promotion (10 inputs to 1 output) and demotion (1 to 10) entries break. A wallet that takes part in such a session therefore reports its own mixing transaction as not denominated: transactionrecord.cpp skips the CoinJoinMixing branch, fAllFromMe and fAllToMe are both false because the other participants' coins are not ours, and the record falls through to TransactionRecord::Other. formatTxType renders that as an empty Type cell with a 0.00 amount and address "(n/a)". The CoinJoin filter in the transaction view stops matching it, and walletview only suppresses desktop popups for the four CoinJoin record types, so these transactions pop a notification even with CoinJoin popups turned off. Replace the count equality with a check that every output is a denominated amount. That holds for standard, promotion and demotion final transactions alike, and together with the existing zero-fee and own-denominated-input/output conditions still excludes ordinary payments, which carry a non-denominated payment or change output.
ShouldPromoteDenoms and ShouldDemoteDenoms require the deficit gap to exceed GAP_THRESHOLD, a constant 10. Both deficits are clamped to [0, nGoal], so the largest gap two denominations can ever show is nGoal itself, which makes the comparison satisfiable only when nGoal > 10. MIN_COINJOIN_DENOMS_GOAL is also 10 and -coinjoindenomsgoal clamps to [10, 100000], so a user running the documented minimum gets promotion and demotion silently disabled: even a wallet holding the goal in smaller denoms and none of the larger never converts. There is no diagnostic, and the full GetDenominationCounts() wallet scan still runs on every maintenance tick once V24 is active. Derive the threshold from the goal instead. At the default goal of 50 it still evaluates to 10, so tuned behavior is unchanged; at the minimum goal it becomes 2, which is reachable while still damping oscillation. Add a test covering the minimum goal, which no existing case exercised - they all use 100.
…ndary Give the test server a session id when it enters a pool state, matching a real session -- AddEntry now rejects entries that do not belong to a live session, and the harness left nSessionID at 0. Add server_addentry_rejects_entries_once_the_session_finalized, which pins the race the admission fix closes: a straggler whose collateral is still committed and which has no entry yet must not be admitted once the pool has moved to POOL_STATE_SIGNING. Add gap_threshold_reachable_at_every_goal, covering MIN_COINJOIN_DENOMS_GOAL, which no existing case exercised, and asserting the default goal keeps the threshold of 10 it was tuned with.
develop replaced the C-style and functional casts across Dash code in e454918; the promotion/demotion work was written before that landed and reintroduced a handful of size_t(...) and (int)... casts in the same files. Convert them so the next sweep does not have to revisit these lines. No behavior change.
Update the release notes for the two behavior changes made while fixing review findings: session rebalance capability now latches on the first participant that declares a direction rather than being fixed by the creator's protocol version, and a DSTX withheld from a pre-70241 peer is announced to it as a plain transaction rather than not at all.
…eview feedback Refines the preceding fixes after a review pass. The DSTX skew tolerance is retied to the deployment's threshold state. The previous revision dropped the activation check entirely, which also gave the reduced penalty to unbalanced DSTXes sent long before V24 could activate. Gate on ThresholdState LOCKED_IN or ACTIVE instead: lock-in is the point from which a peer's tip can legitimately be past activation while ours is not, however many blocks ahead it happens to be, and before it no honest peer applies post-V24 rules. p2p_dstx.py covers both ends -- full penalty far from the boundary, reduced penalty at it. Remaining changes are non-functional: extract CCoinJoinServer::HasSessionCollateral so the membership predicate has one definition instead of three; annotate ConsumeCollateral as requiring cs_coinjoin not be held, since it takes cs_main; rename the IsValidInOuts parameter to fAllowRebalanceShapes, which is what it gates now that callers supply it, and name it at every call site; use std::max in GetGapThreshold; and drop a <ranges> include that <algorithm> already provides.
d59ed79 to
3646c95
Compare
thepastaclaw
left a comment
There was a problem hiding this comment.
Preliminary review — Codex only
A blocking activation-skew issue remains: an upgraded standard mixer can join a rebalance session while more than one block behind the masternode, then reject the valid unbalanced final transaction because client validation only looks one block ahead. The entry-admission race is fixed, but the post-V24 end-to-end session flow remains untested and several same-stack corrective commits should be folded into their originating changes.
Source: reviewer backend models: gpt-5.6-sol (general), gpt-5.6-sol (dash-core-commit-history); final verifier backend model: gpt-5.6-sol. Orchestration-only, not reviewer evidence: openclaw-agent/cliproxy/gpt-5.6-sol.
Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.
Review provenance
- Codex reviewers:
gpt-5.6-sol— general (completed),gpt-5.6-sol— dash-core-commit-history (completed) - Verifier:
gpt-5.6-sol— verifier - Sonnet: not run (deferred by blocker gate)
🔴 1 blocking | 🟡 6 suggestion(s)
6 additional finding(s) omitted (not in diff).
1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `src/coinjoin/client.cpp`:
- [BLOCKING] src/coinjoin/client.cpp:470-499: Final transaction validation only tolerates one block of activation-tip skew
SignFinalTransaction enables rebalance-shaped final transactions only when V24 is active at the local tip or will activate immediately after it. An upgraded wallet performing ordinary 1:1 mixing can still join a session that already contains a rebalancer: DSQ does not advertise the session capability, and the server admits standard participants based on protocol version rather than their local activation state. If the masternode has crossed activation while this client is two or more blocks behind, the client calls IsValidInOuts with fAllowRebalanceShapes=false, rejects the honest unbalanced final transaction, and may have its collateral selected as a non-signer. The new IsPromotionDemotionImminent helper already represents the arbitrary-tip-skew window from LOCKED_IN onward and should also be included in final-transaction validation.
In `<commit:9e83d6a>`:
- [SUGGESTION] <commit:9e83d6a>:1: Fold the two DSTX relay corrections into the relay-gating commit
Commits 9e83d6a3e59 and 972733700a3 directly repair behavior introduced by efff25c2c64. The first ensures oversized balanced DSTXes are not announced to legacy peers, and the second ensures withholding a DSTX does not also suppress its InstantSend lock. Fold both into efff25c2c64 so the relay-gating commit contains its final compatibility behavior and retained history does not expose known-defective intermediate states.
In `<commit:88d4e6b>`:
- [SUGGESTION] <commit:88d4e6b>:1: Fold the null-prevtx guard into the rounds-reset feature
Commit 88d4e6bc65e adds the missing wtxPrev->tx null check in the exact GetRealOutpointCoinJoinRounds path introduced by 9cae6f431a4. It has no independent behavior beyond making the new rounds-reset path safe for a nullable wallet transaction reference. Fold it into 9cae6f431a4 so the feature is safe from its introduction and bisect cannot stop on the null-dereference version.
In `<commit:50c62c9>`:
- [SUGGESTION] <commit:50c62c9>:1: Fold the thread-safety annotation into session gating
Commit 50c62c99d23 only adds GUARDED_BY(cs_coinjoin) to m_fRebalanceSession, which was introduced by 9c29052a912 together with its synchronization contract. Fold the annotation into 9c29052a912 so the member is correctly documented from its introduction.
In `<commit:f7d2bbb>`:
- [SUGGESTION] <commit:f7d2bbb>:1: Fold the session-gating corrections into the gating feature
Commits f7d2bbbc7e0 and 1ff74e99b0a directly correct the session gating introduced by 9c29052a912. The first restores the accepting-state and session-identity checks required for safe entry admission, while the second replaces the creator-version capability latch because it unnecessarily excluded legacy liquidity. Fold both into 9c29052a912, along with the corresponding release-note correction from 0dc132f038d, so the retained gating commit contains the safe and intended semantics.
In `<commit:3646c95>`:
- [SUGGESTION] <commit:3646c95>:1: Collapse the DSTX skew walk-back chain into its final behavior
The skew policy is retained across three deliberate intermediate states: 363d305b02d introduces one-block-only tolerance, 032bc996c49 removes the activation condition entirely because that window is too narrow, and 3646c95b400 restores a LOCKED_IN/ACTIVE condition because the unconditional policy is too broad. Fold the final lock-in-based implementation into 363d305b02d. The plain-transaction downgrade from 032bc996c49 belongs with the relay-gating change in efff25c2c64, with its release-note hunk moved accordingly, so bisect does not expose either known-incorrect penalty policy.
In `<commit:6af461c>`:
- [SUGGESTION] <commit:6af461c>:1: Fold the minimum-goal threshold correction into the feature
Commit 6af461c83aa fixes the constant gap threshold introduced by 49d7d5f8650, which made promotion and demotion unreachable at the documented minimum denomination goal. Because the affected decision functions are new in this PR and the broken behavior never shipped independently, fold the production correction into 49d7d5f8650. The std::max cleanup for the same helper from 3646c95b400 should be folded into that implementation as well; the regression tests in e78b965015e can remain separate.
In `src/test/coinjoin_inouts_tests.cpp`:
- [SUGGESTION] src/test/coinjoin_inouts_tests.cpp:1156-1160: Post-V24 mixing-session flow lacks end-to-end coverage
(existing thread: https://github.com/dashpay/dash/pull/7052#discussion_r3799322585)
The tests still explicitly stop short of activating V24, while p2p_dstx.py covers DSTX scoring and relay behavior rather than an actual mixing session. No test exercises the complete DSA declaration → queue readiness → DSVIN admission → aggregate final transaction → client validation and signing flow for standard, promotion, and demotion participants. This is the central protocol path introduced by the PR and would provide regression coverage for activation skew, declared-direction enforcement, side coverage, and session transitions.
| @@ -474,8 +494,9 @@ bool CCoinJoinClientSession::SignFinalTransaction(CNode& peer, Chainstate& activ | |||
|
|
|||
| // Make sure all inputs/outputs are valid | |||
| PoolMessage nMessageID{MSG_NOERR}; | |||
| CoinJoin::SessionDenomCounts denomCounts; | |||
| if (!IsValidInOuts(active_chainstate, m_isman, mempool, finalMutableTransaction.vin, finalMutableTransaction.vout, | |||
| nSessionDenom, nMessageID, nullptr)) { | |||
| nSessionDenom, /*fAllowRebalanceShapes=*/fV24Active, nMessageID, nullptr, /*fFinalTx=*/true, &denomCounts)) { | |||
There was a problem hiding this comment.
🔴 Blocking: Final transaction validation only tolerates one block of activation-tip skew
SignFinalTransaction enables rebalance-shaped final transactions only when V24 is active at the local tip or will activate immediately after it. An upgraded wallet performing ordinary 1:1 mixing can still join a session that already contains a rebalancer: DSQ does not advertise the session capability, and the server admits standard participants based on protocol version rather than their local activation state. If the masternode has crossed activation while this client is two or more blocks behind, the client calls IsValidInOuts with fAllowRebalanceShapes=false, rejects the honest unbalanced final transaction, and may have its collateral selected as a non-signer. The new IsPromotionDemotionImminent helper already represents the arbitrary-tip-skew window from LOCKED_IN onward and should also be included in final-transaction validation.
| const bool fV24Active = CoinJoin::IsPromotionDemotionActive(active_chainstate.m_chainman, /*fNextBlock=*/true) || | |
| CoinJoin::IsPromotionDemotionImminent(active_chainstate.m_chainman); |
source: ['codex']
There was a problem hiding this comment.
No action — this is a deliberate policy choice, not an oversight.
The mechanism is described correctly: a 70241 client mixing 1:1 can join a session that already holds a rebalancer, dsq does not advertise that capability, and the server gates standard participants on protocol version rather than on their local activation state. So a client two or more blocks behind at the boundary will refuse to sign an honest unbalanced final transaction and can have its collateral charged.
The tolerance is intentionally one block. Participants and masternodes in a live mixing session are within a block of each other in practice, and a node further behind than that at the activation height is accepting the consequences of its own sync state. An earlier revision of this branch did widen the window — keying it on the V24 deployment being LOCKED_IN — and that was reverted in f232f02: V24's nWindowSize is 4032 blocks, so what was labelled a tip-skew tolerance was really a week-long amnesty for malformed DSTXes and unbalanced final transactions. Widening it correctly would need the actual activation height rather than a threshold state, and the complexity is not justified by the exposure, which is a one-time window at a single activation height.
The point about CMasternodeSync::IsBlockchainSynced() is fair and was not previously on the record: it stays true after its sync phase and does not guarantee the tip is within one block of every peer. That bears on how often the case arises, not on whether the trade-off is acceptable, which is the part that was decided deliberately.
Both paths now apply the same one-block rule — relay in ValidateDSTX and signing in SignFinalTransaction — and p2p_dstx.py covers both sides of the boundary.
🤖 Posted autonomously by Claude on behalf of pasta.
There was a problem hiding this comment.
Resolved in e6687d9 — Final transaction validation only tolerates one block of activation-tip skew no longer present.
Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.
…ansaction SignFinalTransaction allowed rebalance shapes only when V24 was active at our tip or would activate in the very next block. That one-block window is too narrow, because a wallet mixing 1:1 can be admitted to a session that already holds a rebalance participant: dsq does not advertise the session's capability, and the masternode admits standard participants on protocol version without consulting their activation state. A wallet two or more blocks behind at the boundary therefore applies pre-V24 rules to an honest unbalanced final transaction, fails the inputs-equal-outputs check, and refuses to sign - after which ChargeFees in POOL_STATE_SIGNING can select its collateral as a non-signer. Honest wallet, honest masternode, honest transaction, lost collateral. Use IsPromotionDemotionImminent here too, so the tolerance covers the whole window in which a masternode can legitimately be ahead of us rather than a single block. This is the same asymmetry already fixed on the DSTX relay path: the question is not whether V24 is active for us, but whether the peer that sent this could legitimately be past activation. The wallet still verifies its own inputs and outputs are present, that all amounts are valid denominations, that values balance with no fee, and that its side has cover, so nothing that protects it is relaxed. IsPromotionDemotionActive's fNextBlock parameter now has no caller passing true, so drop it.
Replaces the lock-in-based tolerance from the previous two commits. IsPromotionDemotionImminent treated the whole LOCKED_IN period as skew, but V24's nWindowSize is 4032 blocks -- roughly a week -- so what was described as a tip-skew tolerance was really a week-long amnesty for malformed DSTXes and unbalanced final transactions. Participants and masternodes in a live mixing session are within a block of each other in practice, so tolerate exactly that, consistently on both paths: a DSTX from a masternode one block ahead keeps the small PREMATURE penalty rather than the full structural one, and a final transaction that is unbalanced because we are one block short of activation is still signed. A node further behind than that takes the full penalty and risks the collateral -- at that point it has bigger problems than mixing. IsPromotionDemotionActive keeps its fNextBlock parameter, which both paths now use.
GetQueueItemAndTry() marks a queue tried before handing it out, and it only ever hands out a queue once. JoinExistingQueue() then dropped queues whose denomination didn't match the promotion/demotion target after they had already been marked, so a single rebalance pass consumed every pending announcement: the following demotion attempt, the remaining denomination pairs and the standard JoinExistingQueue() call all saw an empty list and fell back to StartNewQueue(). Move the denomination filter into GetQueueItemAndTry() so a queue it skips is left untried and fTried keeps meaning "we actually tried this queue".
CheckPool() dropped a session whose entries are all in but leave a side of the session denomination uncovered without notifying anyone, unlike every other path that abandons a session. Participants kept their inputs locked and their collateral committed until their own timeout expired. Relay ERR_SESSION first, which makes the clients return their keys, unlock their coins and reset immediately. It has to precede SetNull(), which clears the entries the relay iterates.
Post-V24 IsValidStructure() only capped the input and output counts independently, putting no relation between them. Since the denominated-output check passes vacuously on an empty vout, a DSTX with 200 inputs and no outputs passed a gate that previously required equal counts, and was then stored and relayed. IsValidInOuts() does not run on DSTXes received from the network, so nothing else caught it. Add the relations a transaction composable from valid entries must satisfy, neither of which needs UTXO access. Neither side may exceed PROMOTION_RATIO times the other, since an all-promotion transaction carries at most PROMOTION_RATIO inputs per output and an all-demotion one the mirror. And with p promotions, d demotions and standard entries contributing equally to both sides, inputs - outputs is exactly (PROMOTION_RATIO - 1) * (p - d), so the difference between the sides is a multiple of that step. Balanced transactions above the pre-V24 count cap stay valid. A rebalance session caps every entry at PROMOTION_RATIO coins per side, standard ones included, so one promotion, one demotion and eighteen standard entries at that size come to 191 inputs and 191 outputs. Peers below COINJOIN_REBALANCE_VERSION cannot accept that, which is why CanAnnounceDstxTo withholds it from them rather than IsValidStructure rejecting it. Existing cases that stood in for a promotion shape by dropping an output relied on the looser rule: 3 inputs and 2 outputs is not composable from any session, so it is now rejected regardless of where the tip sits relative to activation. p2p_dstx.py builds a real promotion shape instead - one promotion plus two standard entries, 12 inputs and 3 outputs - which keeps all three of its unbalanced cases meaningful, including the post-activation one that expects the transaction to be structurally valid and reach the masternode lookup. output_limit_postfork asserted 30 inputs and 200 outputs would be valid post-V24, whose difference of 170 is not a multiple of PROMOTION_RATIO - 1; it now uses the input count of an all-demotion session at the output cap, and scales that shape past the cap for the rejection it means to test.
thepastaclaw
left a comment
There was a problem hiding this comment.
Preliminary review — Codex only
Final-transaction validation still exposes an upgraded standard mixer to collateral loss when its active chain is more than one block behind the masternode at V24 activation. The post-V24 session flow also lacks end-to-end coverage, and the retained commit stack contains several direct corrective follow-ups that should be folded into the changes they repair. Source: reviewers — gpt-5.6-sol (general), gpt-5.6-sol (dash-core-commit-history); final verifier — gpt-5.6-sol. Orchestration-only, not reviewer evidence: openclaw-agent/cliproxy/gpt-5.6-sol.
Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.
Review provenance
- Codex reviewers:
gpt-5.6-sol— general (completed),gpt-5.6-sol— dash-core-commit-history (completed) - Verifier:
gpt-5.6-sol— verifier - Sonnet: not run (deferred by blocker gate)
🔴 1 blocking | 🟡 6 suggestion(s) | 💬 1 nitpick(s)
7 additional finding(s) omitted (not in diff).
2 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `<commit:9e83d6a>`:
- [SUGGESTION] <commit:9e83d6a>:1: Fold the two DSTX relay corrections into the relay-gating commit
Commits `9e83d6a3e59` and `972733700a3` directly repair behavior introduced by `efff25c2c64`: the first adds the missing legacy structural-size check for balanced DSTXes, and the second restores InstantSend-lock relay after the new DSTX-withholding control flow suppressed it. Fold their final behavior into `efff25c2c64` so the relay-gating commit is correct at its introduction and retained history does not expose either known-defective intermediate state.
In `<commit:88d4e6b>`:
- [SUGGESTION] <commit:88d4e6b>:1: Fold the null-prevtx guard into the rounds-reset feature
Commit `88d4e6bc65e` adds the missing `wtxPrev->tx` null check in the exact `GetRealOutpointCoinJoinRounds()` path introduced by `9cae6f431a4`. The guard has no independent behavior beyond making that new rounds-reset path safe, so fold it into `9cae6f431a4` rather than retaining a bisectable null-dereference state.
In `<commit:50c62c9>`:
- [SUGGESTION] <commit:50c62c9>:1: Fold the thread-safety annotation into session gating
Commit `50c62c99d23` only adds `GUARDED_BY(cs_coinjoin)` to `m_fRebalanceSession`, which was introduced with its synchronization contract in `9c29052a912`. Fold the annotation into `9c29052a912` so the member's locking requirement is documented from its introduction.
In `<commit:f7d2bbb>`:
- [SUGGESTION] <commit:f7d2bbb>:1: Fold the session-gating corrections into the gating feature
Commits `f7d2bbbc7e0` and `1ff74e99b0a` directly correct session gating introduced by `9c29052a912`: they restore the accepting-state and session-identity checks required for safe entry admission and replace creator-version capability latching with admission-based gating so legacy liquidity is not excluded unnecessarily. Fold these changes, together with the corresponding release-note correction in `0dc132f038d`, into `9c29052a912` so the retained feature commit contains the intended safe semantics.
In `<commit:3646c95>`:
- [SUGGESTION] <commit:3646c95>:1: Collapse the DSTX skew walk-back chain into its final behavior
The stack preserves five contradictory skew-policy states: `363d305b02d` introduces one-block tolerance, `032bc996c49` makes DSTX tolerance unconditional, `3646c95b400` changes it to the LOCKED_IN/ACTIVE window, `e6687d91b1d` extends that window to final-transaction validation, and `f232f024953` removes the helper and restores one-block behavior on both paths. Fold the intended implementation into `363d305b02d`, move the independently retained plain-transaction downgrade from `032bc996c49` into `efff25c2c64`, and distribute unrelated cleanup from `3646c95b400` to the commits that introduced the affected code. This avoids permanent bisect stops on policies the same stack later declares too narrow, too broad, or week-long.
In `<commit:6af461c>`:
- [SUGGESTION] <commit:6af461c>:1: Fold the minimum-goal threshold correction into the feature
Commit `6af461c83aa` corrects the fixed `GAP_THRESHOLD` introduced by `49d7d5f8650`, which made promotion and demotion unreachable at the documented minimum denomination goal. Fold the adaptive production implementation into `49d7d5f8650`, including the `std::max` cleanup for the same helper currently carried by `3646c95b400`. The focused regression tests in `e78b965015e` can remain separate.
In `<commit:49d7d5f>`:
- [NITPICK] <commit:49d7d5f>:1: Remove the nonexistent wallet helper from the feature message
The wallet section of `49d7d5f8650` says `CountCoinsByDenomination()` backs the rebalance decision, but that symbol is neither introduced by the commit nor present at the final head. Reword the bullet to name the actual `GetDenominationCounts()` and `SelectFullyMixedForPromotion()` helpers so the permanent commit message accurately describes the implementation.
In `src/coinjoin/client.cpp`:
- [BLOCKING] src/coinjoin/client.cpp:470-475: Final transaction validation only tolerates one block of activation-tip skew
(existing thread: https://github.com/dashpay/dash/pull/7052#discussion_r3806190889)
`SignFinalTransaction()` permits rebalance-shaped final transactions only when V24 is active at the client's tip or activates in the immediately following block. A protocol-70241 client doing ordinary 1:1 mixing can nevertheless join a session that already contains a rebalancer because DSQ does not advertise that capability and the server admits upgraded standard participants without checking their local activation state. If the masternode has crossed activation while this client is two or more blocks behind, the honest unbalanced final transaction is passed to `IsValidInOuts()` with `fAllowRebalanceShapes=false`; the client refuses to sign and its collateral can subsequently be selected by `ChargeFees()` as belonging to a non-signer. `CMasternodeSync` can remain complete while the active chain temporarily trails peers, so treating larger skew as the client's responsibility does not prevent this collateral-loss path. Final-transaction validation needs an allowance covering every state in which the negotiated upgraded masternode can legitimately be past activation, not only a one-block lookahead.
In `src/test/coinjoin_inouts_tests.cpp`:
- [SUGGESTION] src/test/coinjoin_inouts_tests.cpp:1156-1160: Post-V24 mixing-session flow lacks end-to-end coverage
(existing thread: https://github.com/dashpay/dash/pull/7052#discussion_r3799322585)
The tests cover shape classification, aggregate composition, serialization, collateral binding, side coverage, and activation-dependent DSTX scoring, but they explicitly stop before exercising a V24-active mixing session. No test drives the complete DSA declaration → queue readiness → DSVIN admission → aggregate final transaction → client validation and signing flow for standard, promotion, and demotion participants. This is the central protocol path introduced by the PR and would exercise activation skew, declared-direction enforcement, side coverage, admission state transitions, and final signing together.
thepastaclaw
left a comment
There was a problem hiding this comment.
Preliminary review — Codex only
Three blocking CoinJoin correctness issues remain at the exact head: final-transaction validation can penalize upgraded clients whose chain is more than one block behind at V24 activation, concurrent entry admission can commit the same mixing prevout twice, and CheckPool() makes finalization decisions from non-atomic session snapshots. The post-V24 session lifecycle still lacks end-to-end coverage, and the retained history contains several direct corrective follow-ups; one lower-priority commit-message nitpick was omitted under the 10-comment budget.
Source: reviewer backend models: gpt-5.6-sol (general), gpt-5.6-sol (dash-core-commit-history); final verifier backend model: gpt-5.6-sol. Orchestration-only, not reviewer evidence: openclaw-agent/cliproxy/gpt-5.6-sol.
Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.
Review provenance
- Codex reviewers:
gpt-5.6-sol— general (completed),gpt-5.6-sol— dash-core-commit-history (completed) - Verifier:
gpt-5.6-sol— verifier - Sonnet: not run (deferred by blocker gate)
🔴 3 blocking | 🟡 6 suggestion(s)
6 additional finding(s) omitted (not in diff).
2 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `src/coinjoin/server.cpp`:
- [BLOCKING] src/coinjoin/server.cpp:787-828: Duplicate mixing prevouts can pass entry admission
`AddEntry()` checks submitted inputs against entries that are already in `vecEntries`, but releases `cs_coinjoin` for UTXO validation and does not repeat the input-conflict check in the final locked commit block. Two concurrent entries with different accepted collaterals can therefore both validate the same prevout before either is committed, after which both are appended. The resulting final transaction contains a duplicate input and cannot complete normally; clients will also reject it when the repeated coin's script fails the duplicate-script check. Recheck every submitted prevout against `vecEntries` under the same final lock used for `vecEntries.push_back()`. The reported single-entry variant does not apply because `IsValidInOuts()` rejects a repeated UTXO's duplicate script, but the concurrent admission path remains exploitable.
- [BLOCKING] src/coinjoin/server.cpp:302-329: Side coverage and session fullness are sampled non-atomically
`CheckPool()` obtains `sides` under `cs_coinjoin` through `GetMixSideCounts()`, then separately obtains the entry count and reads `vecSessionCollaterals.size()` without the lock. The scheduler can snapshot one received promotion as uncovered, then another thread can commit the second promotion before the fullness check. `CheckPool()` then observes that all entries arrived but applies the stale side count and resets an honestly covered session. The unlocked collateral-vector access can also race `SetNull()`, and competing `CheckPool()` calls can make finalization/reset decisions for different session states. Capture the session ID, state, entry count, collateral count, and side counts in one locked snapshot, then revalidate that session before finalizing or resetting it.
In `<commit:9e83d6a>`:
- [SUGGESTION] <commit:9e83d6a>: Fold the two DSTX relay corrections into the relay-gating commit
Commits `9e83d6a3e59` and `972733700a3` directly repair behavior introduced by `efff25c2c64`: the first adds the omitted legacy structural-size check for balanced DSTXes, and the second restores InstantSend-lock relay after the new DSTX-withholding control flow suppressed it. Fold the final legacy-size and inventory/IS-lock behavior into `efff25c2c64` so the relay-gating commit is correct at its introduction and retained history does not expose either known-defective intermediate state.
In `<commit:88d4e6b>`:
- [SUGGESTION] <commit:88d4e6b>: Fold the null-prevtx guard into the rounds-reset feature
Commit `88d4e6bc65e` adds the missing `wtxPrev->tx` null check in the exact `GetRealOutpointCoinJoinRounds()` path introduced by `9cae6f431a4`. The guard has no independent behavior beyond making that newly introduced path safe, so fold it into `9cae6f431a4` rather than retaining a bisectable null-dereference state.
In `<commit:f7d2bbb>`:
- [SUGGESTION] <commit:f7d2bbb>: Fold the session-gating corrections into the gating feature
The stack retains several direct corrections to session gating introduced by `9c29052a912`: `50c62c99d23` adds the synchronization annotation that should accompany `m_fRebalanceSession` from its introduction, `f7d2bbbc7e0` restores the accepting-state and session-identity checks needed for safe entry admission, `1ff74e99b0a` replaces creator-version capability latching with admission-based gating, and `26d0a987b42` adds the participant notification omitted from the uncovered-session reset. Fold these final semantics, together with the corresponding release-note correction in `0dc132f038d`, into `9c29052a912` so the retained gating commit is safe and accurately documented at its introduction.
In `<commit:3646c95>`:
- [SUGGESTION] <commit:3646c95>: Collapse the DSTX skew walk-back chain into its final behavior
The retained stack walks through five contradictory skew policies: `363d305b02d` introduces one-block tolerance, `032bc996c49` makes DSTX tolerance unconditional, `3646c95b400` changes it to the LOCKED_IN/ACTIVE window, `e6687d91b1d` extends that window to final-transaction validation, and `f232f024953` removes the helper and returns both paths to one-block behavior. Once the correctness issue above is resolved, fold the intended final policy into `363d305b02d`; move the independently retained plain-transaction relay downgrade from `032bc996c49` into `efff25c2c64`, and distribute unrelated cleanup from `3646c95b400` to the commits that introduced the affected code.
In `<commit:6af461c>`:
- [SUGGESTION] <commit:6af461c>: Fold the minimum-goal threshold correction into the feature
Commit `6af461c83aa` corrects the fixed `GAP_THRESHOLD` introduced by `49d7d5f8650`, which made promotion and demotion unreachable at the documented minimum denomination goal. Fold the adaptive production implementation into `49d7d5f8650`, including the `std::max` cleanup for the same helper currently carried by `3646c95b400`. The focused regression tests in `e78b965015e` can remain separate.
In `<commit:123858d>`:
- [SUGGESTION] <commit:123858d>: Fold the latest direct corrections into their originating commits
`123858d9a24` directly repairs queue selection introduced by `49d7d5f8650`, which marked denomination-mismatched queues tried before filtering them, while `adb2765551d` adds structural count relations omitted from the post-V24 DSTX rules introduced by the same feature commit. Fold those corrections and their tests into `49d7d5f8650` and the corresponding test commit. The participant-notification correction in `26d0a987b42` is part of the session-gating consolidation described separately above.
In `src/coinjoin/client.cpp`:
- [BLOCKING] src/coinjoin/client.cpp:470-501: Final transaction validation only tolerates one block of activation-tip skew
(existing thread: https://github.com/dashpay/dash/pull/7052#discussion_r3806190889)
`SignFinalTransaction()` enables rebalance-shaped final-transaction validation only when V24 is active at the client's tip or activates in the immediately following block. A protocol-70241 client performing ordinary 1:1 mixing can nevertheless join a session that already contains a rebalancer because DSQ does not advertise that capability and the server gates upgraded standard participants by protocol version, not by their local activation state. If the masternode has crossed activation while this client is two or more blocks behind, the honest unbalanced final transaction reaches `IsValidInOuts()` with `fAllowRebalanceShapes=false`; the client refuses to sign and can subsequently have its collateral selected by `ChargeFees()` as a non-signer. `CMasternodeSync::IsBlockchainSynced()` remains true after its synchronization phase and does not guarantee that the active chain is currently within one block of every peer, so the comment treating greater skew as the client's problem does not prevent collateral loss.
In `src/test/coinjoin_inouts_tests.cpp`:
- [SUGGESTION] src/test/coinjoin_inouts_tests.cpp:1158-1168: Post-V24 mixing-session flow lacks end-to-end coverage
(existing thread: https://github.com/dashpay/dash/pull/7052#discussion_r3799322585)
The tests cover entry shapes, aggregate composition, serialization, collateral binding, side coverage, queue filtering, and activation-dependent DSTX scoring, but they explicitly stop before exercising a V24-active mixing session. No test drives the complete DSA declaration → queue readiness → DSVIN admission → aggregate final transaction → client validation and signing flow for standard, promotion, and demotion participants. This central protocol path would jointly exercise the activation boundary, declared-direction enforcement, side coverage, entry admission, finalization, and signing lifecycle, including the concurrency-sensitive behavior implicated by the blocking findings.
| std::vector<CTxIn> vin; | ||
| vin.reserve(entry.vecTxDSIn.size()); | ||
| for (const auto& txin : entry.vecTxDSIn) { | ||
| LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- txin=%s\n", __func__, txin.ToString()); | ||
| LOCK(cs_coinjoin); | ||
| for (const auto& inner_entry : vecEntries) { | ||
| if (std::ranges::any_of(inner_entry.vecTxDSIn, | ||
| [&txin](const auto& txdsin) { return txdsin.prevout == txin.prevout; })) { | ||
| LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR: already have this txin in entries\n", __func__); | ||
| nMessageIDRet = ERR_ALREADY_HAVE; | ||
| // Two peers sent the same input? Can't really say who is the malicious one here, | ||
| // could be that someone is picking someone else's inputs randomly trying to force | ||
| // collateral consumption. Do not punish. | ||
| return false; | ||
| } | ||
| } | ||
| vin.emplace_back(txin); | ||
| } | ||
|
|
||
| bool fConsumeCollateral{false}; | ||
| if (!IsValidInOuts(m_chainman.ActiveChainstate(), m_isman, mempool, vin, entry.vecTxOut, session_denom, | ||
| nMessageIDRet, &fConsumeCollateral)) { | ||
| /*fAllowRebalanceShapes=*/fRebalanceSession, nMessageIDRet, &fConsumeCollateral)) { | ||
| LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR! IsValidInOuts() failed: %s\n", __func__, CoinJoin::GetMessageByID(nMessageIDRet).translated); | ||
| if (fConsumeCollateral) { | ||
| CTransactionRef txCollateralToConsume; | ||
| { | ||
| LOCK(cs_coinjoin); | ||
| if (IsCurrentSession(session_id, session_denom, POOL_STATE_ACCEPTING_ENTRIES)) { | ||
| const auto it = std::ranges::find_if(vecSessionCollaterals, [&entry](const auto& txCollateral) { | ||
| return *entry.txCollateral == *txCollateral; | ||
| }); | ||
| if (it != vecSessionCollaterals.end()) { | ||
| txCollateralToConsume = *it; | ||
| } | ||
| } | ||
| } | ||
| if (txCollateralToConsume) { | ||
| ConsumeCollateral(txCollateralToConsume); | ||
| } | ||
| ConsumeCollateralIfCurrentSession(session_id, entry.txCollateral); | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| { | ||
| LOCK(cs_coinjoin); | ||
| if (!IsCurrentSession(session_id, session_denom, POOL_STATE_ACCEPTING_ENTRIES)) { | ||
| // cs_coinjoin was released around the UTXO checks above, so the scheduler thread may | ||
| // have finalized or reset the session in between; re-verify so admission stays atomic | ||
| // with the state, membership and duplicate-use checks. Admitting into a session that | ||
| // already built its final transaction would add an entry no one can sign, stalling it | ||
| // until the signing timeout charges the honest participants. | ||
| if (!IsCurrentSession(session_id) || !HasSessionCollateral(entry.txCollateral) || | ||
| std::ranges::any_of(vecEntries, hasEntryForCollateral)) { | ||
| LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR: session changed while validating the entry!\n", __func__); | ||
| nMessageIDRet = ERR_SESSION; | ||
| return false; | ||
| } | ||
| if (static_cast<size_t>(GetEntriesCountLocked()) >= vecSessionCollaterals.size()) { | ||
| nMessageIDRet = ERR_ENTRIES_FULL; | ||
| return false; | ||
| } | ||
| for (const auto& txin : vin) { | ||
| for (const auto& inner_entry : vecEntries) { | ||
| if (std::ranges::any_of(inner_entry.vecTxDSIn, | ||
| [&txin](const auto& txdsin) { return txdsin.prevout == txin.prevout; })) { | ||
| LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR: already have this txin in entries\n", | ||
| __func__); | ||
| nMessageIDRet = ERR_ALREADY_HAVE; | ||
| // Two peers sent the same input? Can't really say who is the malicious one here, | ||
| // could be that someone is picking someone else's inputs randomly trying to force | ||
| // collateral consumption. Do not punish. | ||
| return false; | ||
| } | ||
| } | ||
| } | ||
| vecEntries.push_back(entry); |
There was a problem hiding this comment.
🔴 Blocking: Duplicate mixing prevouts can pass entry admission
AddEntry() checks submitted inputs against entries that are already in vecEntries, but releases cs_coinjoin for UTXO validation and does not repeat the input-conflict check in the final locked commit block. Two concurrent entries with different accepted collaterals can therefore both validate the same prevout before either is committed, after which both are appended. The resulting final transaction contains a duplicate input and cannot complete normally; clients will also reject it when the repeated coin's script fails the duplicate-script check. Recheck every submitted prevout against vecEntries under the same final lock used for vecEntries.push_back(). The reported single-entry variant does not apply because IsValidInOuts() rejects a repeated UTXO's duplicate script, but the concurrent admission path remains exploitable.
source: ['codex']
There was a problem hiding this comment.
No action — the concurrency premise does not hold.
This finding requires two AddEntry() calls to overlap ("two concurrent entries with different accepted collaterals can therefore both validate the same prevout before either is committed"). They cannot.
Message processing is single-threaded. CConnman::ThreadMessageHandler is created once (net.cpp:4090), takes LOCK(NetEventsInterface::g_msgproc_mutex) for its entire lifetime, and iterates peers serially calling ProcessMessages(pnode, ...) (net.cpp:3676-3703). CCoinJoinServer::AddEntry has exactly one caller, ProcessDSVIN at server.cpp:221, reached only from CCoinJoinServer::ProcessMessage. So two dsvin messages from different peers are strictly serialized, and the interleaving described cannot occur. The only thread that races AddEntry is the scheduler (CheckPool/CheckTimeout), which never appends to vecEntries — that race is real and is what the accepting-state and session-identity re-checks in the final locked block already address.
One caveat worth recording, since it is the reason this looked reachable: the input-conflict check is correct because of that single-thread property, not because it is self-contained. Nothing at the call site documents or enforces the assumption, so if message processing were ever parallelized this would silently become the bug described here. Re-checking the submitted prevouts in the final locked block would be cheap insurance and would pair naturally with hoisting the per-input LOCK(cs_coinjoin) out of that loop, which currently acquires and releases the lock once per input. Neither is a correctness fix today, so leaving it as-is rather than adding a guard whose necessity a future reader could not reconstruct.
🤖 Posted autonomously by Claude on behalf of pasta.
There was a problem hiding this comment.
Resolved in d1fca89 — Duplicate mixing prevouts can pass entry admission no longer present.
Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.
| if (int entries = GetEntriesCount(); entries != 0) | ||
| LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- entries count %lu\n", entries); | ||
|
|
||
| // PRIVACY: a final transaction is only worth publishing when each side of the session | ||
| // denomination is occupied by nobody or by at least two participants; a lone participant | ||
| // on a side would have the only coins of that size there and be trivially identifiable. | ||
| const auto sides = GetMixSideCounts(); | ||
|
|
||
| // If we have an entry for each collateral, then create final tx | ||
| if (nState == POOL_STATE_ACCEPTING_ENTRIES && static_cast<size_t>(GetEntriesCount()) == vecSessionCollaterals.size()) { | ||
| LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- FINALIZE TRANSACTIONS\n"); | ||
| CreateFinalTransaction(); | ||
| if (sides.IsCovered()) { | ||
| LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- FINALIZE TRANSACTIONS\n"); | ||
| CreateFinalTransaction(); | ||
| return; | ||
| } | ||
| // The participant set is frozen once entries are being accepted, so this session can | ||
| // never gain the missing counterparty - reset instead of stalling everyone until | ||
| // timeout. Shouldn't happen: admission checks the declared shapes up front. | ||
| LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- all entries received but a session denom side is uncovered (%d in, %d out), resetting session\n", | ||
| sides.inputs, sides.outputs); | ||
| // Tell the participants before dropping them, so they release their inputs and collateral | ||
| // right away instead of waiting out their own timeout. Must precede SetNull(), which | ||
| // clears the entries this iterates. | ||
| RelayCompletedTransaction(ERR_SESSION); | ||
| WITH_LOCK(cs_coinjoin, SetNull()); | ||
| return; |
There was a problem hiding this comment.
🔴 Blocking: Side coverage and session fullness are sampled non-atomically
CheckPool() obtains sides under cs_coinjoin through GetMixSideCounts(), then separately obtains the entry count and reads vecSessionCollaterals.size() without the lock. The scheduler can snapshot one received promotion as uncovered, then another thread can commit the second promotion before the fullness check. CheckPool() then observes that all entries arrived but applies the stale side count and resets an honestly covered session. The unlocked collateral-vector access can also race SetNull(), and competing CheckPool() calls can make finalization/reset decisions for different session states. Capture the session ID, state, entry count, collateral count, and side counts in one locked snapshot, then revalidate that session before finalizing or resetting it.
source: ['codex']
There was a problem hiding this comment.
Resolved in d1fca89 — Side coverage and session fullness are sampled non-atomically no longer present.
Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.
CheckPool() sampled the session a piece at a time: GetMixSideCounts() and GetEntriesCount() each took and released cs_coinjoin separately, and vecSessionCollaterals.size() was read with no lock at all. Since CheckPool() runs on the scheduler thread while the message-handling thread appends entries, the side counts could be read before the last entry arrived and the entry count after it. The result describes no state the session was ever in: all entries accounted for, but a side of the session denomination apparently uncovered. CheckPool() then took that as the unreachable case and reset a session that was covered and about to finalize. The unlocked collateral read could also race SetNull() clearing the vector. Take session id, state, entry count, collateral count and side counts under a single lock, and decide from that snapshot. The actions themselves run without the lock, so the two that mutate the session -- finalizing and resetting -- revalidate that the snapshot still describes the live session before acting, since it may have timed out and been replaced in the meantime.
thepastaclaw
left a comment
There was a problem hiding this comment.
Preliminary review — Codex only
Timeout finalization still relies on side coverage captured before fee charging, so an entry committed during that interval can make the live session uncovered before the final transaction is built. The post-V24 session lifecycle also lacks end-to-end coverage, and the retained history contains several direct corrective follow-ups that should be folded into their originating commits.
Source: reviewers — gpt-5.6-sol (general), gpt-5.6-sol (dash-core-commit-history); final verifier — gpt-5.6-sol. Orchestration-only, not reviewer evidence: openclaw-agent/cliproxy/gpt-5.6-sol.
Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.
Review provenance
- Codex reviewers:
gpt-5.6-sol— general (completed),gpt-5.6-sol— dash-core-commit-history (completed) - Verifier:
gpt-5.6-sol— verifier - Sonnet: not run (deferred by blocker gate)
🔴 1 blocking | 🟡 6 suggestion(s) | 💬 1 nitpick(s)
7 additional finding(s) omitted (not in diff).
1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `src/coinjoin/server.cpp`:
- [BLOCKING] src/coinjoin/server.cpp:343-348: Timeout finalization can use stale side coverage
The timeout branch decides that the session is covered from `snap.sides`, but then releases `cs_coinjoin`, calls `ChargeFees()`, and only rechecks the session identity/state in `CreateFinalTransaction()`. A DSVIN already validating on the message-handler thread can commit during that interval because the session remains in `POOL_STATE_ACCEPTING_ENTRIES`. For example, a snapshot containing three demotion entries has covered sides 0/3; if the admitted set also contains two promoters, one promoter can commit while fees are charged, changing the live sides to 1/3. `CreateFinalTransaction()` then includes that newly committed entry and publishes an uncovered transaction with a lone promoter. Recheck `GetMixSideCountsLocked().IsCovered()` and the minimum entry count under the same `cs_coinjoin` lock used to build the final transaction.
In `<commit:9e83d6a>`:
- [SUGGESTION] <commit:9e83d6a>:1: Fold the two DSTX relay corrections into the relay-gating commit
Commits `9e83d6a3e59` and `972733700a3` directly repair behavior introduced by `efff25c2c64`: the first adds the omitted legacy structural-size check for balanced DSTXes, and the second restores InstantSend-lock relay after the DSTX-withholding control flow suppressed it. Fold both corrections into `efff25c2c64` so the relay-gating commit is correct at introduction and retained history does not expose either known-defective relay state.
In `<commit:88d4e6b>`:
- [SUGGESTION] <commit:88d4e6b>:1: Fold the null-prevtx guard into the rounds-reset feature
Commit `88d4e6bc65e` adds the missing `wtxPrev->tx` null check in the exact `GetRealOutpointCoinJoinRounds()` branch introduced by `9cae6f431a4`. The guard has no independent behavior beyond making that newly introduced path safe, so fold it into `9cae6f431a4` rather than retaining a bisectable null-dereference state.
In `<commit:f7d2bbb>`:
- [SUGGESTION] <commit:f7d2bbb>:1: Fold the session-gating corrections into the gating feature
The stack retains several direct corrections to session gating introduced by `9c29052a912`: `50c62c99d23` adds its synchronization annotation, `f7d2bbbc7e0` restores safe session/state validation during entry admission, `1ff74e99b0a` replaces creator-version capability latching, `26d0a987b42` adds participant notification before an uncovered-session reset, and `d1fca890eda` repairs the non-atomic `CheckPool()` decision. Fold these final semantics, along with the corresponding release-note correction in `0dc132f038d`, into `9c29052a912` so the gating feature is safe and accurately documented from its introduction.
In `<commit:3646c95>`:
- [SUGGESTION] <commit:3646c95>:1: Collapse the DSTX skew walk-back chain into its final behavior
The retained stack walks through five contradictory skew policies: `363d305b02d` introduces one-block tolerance, `032bc996c49` makes DSTX tolerance unconditional, `3646c95b400` changes it to the LOCKED_IN/ACTIVE window, `e6687d91b1d` extends that window to final-transaction validation, and `f232f024953` restores one-block behavior on both paths. Fold the intended final policy into `363d305b02d`; move the independently retained plain-transaction relay downgrade from `032bc996c49` into `efff25c2c64`, and distribute unrelated cleanup from `3646c95b400` to the commits that introduced the affected code.
In `<commit:6af461c>`:
- [SUGGESTION] <commit:6af461c>:1: Fold the minimum-goal threshold correction into the feature
Commit `6af461c83aa` corrects the fixed `GAP_THRESHOLD` introduced by `49d7d5f8650`, which made promotion and demotion unreachable at the documented minimum denomination goal. Fold the adaptive production implementation into `49d7d5f8650`, including the `std::max` cleanup for the same helper currently carried by `3646c95b400`. The focused regression tests in `e78b965015e` can remain separate.
In `<commit:123858d>`:
- [SUGGESTION] <commit:123858d>:1: Fold the latest direct corrections into their originating commits
`123858d9a24` directly repairs queue selection introduced by `49d7d5f8650`, which marked denomination-mismatched queues tried before filtering them, while `adb2765551d` adds structural count relations omitted from the post-V24 DSTX rules introduced by that feature. Fold these production corrections into `49d7d5f8650` and move their tests into the corresponding test commit. The participant-notification and pool-snapshot corrections belong with the session-gating consolidation identified separately.
In `<commit:49d7d5f>`:
- [NITPICK] <commit:49d7d5f>:1: Remove the nonexistent wallet helper from the feature message
The wallet section of `49d7d5f8650` says `CountCoinsByDenomination()` backs the rebalance decision and selection, but that symbol is neither introduced by the commit nor present at the final head. Reword the bullet to name the actual `GetDenominationCounts()` and `SelectFullyMixedForPromotion()` helpers so the permanent commit message accurately describes the implementation.
In `src/test/coinjoin_inouts_tests.cpp`:
- [SUGGESTION] src/test/coinjoin_inouts_tests.cpp:1158-1168: Post-V24 mixing-session flow lacks end-to-end coverage
(existing thread: https://github.com/dashpay/dash/pull/7052#discussion_r3799322585)
The tests cover entry shapes, aggregate composition, serialization, collateral binding, side coverage, queue filtering, and activation-dependent DSTX scoring, but they still stop before exercising a V24-active mixing session. No test drives the complete DSA declaration → queue readiness → DSVIN admission → aggregate final transaction → client validation and signing flow for standard, promotion, and demotion participants. Add a V24-active functional CoinJoin scenario so the activation gate, declared-direction enforcement, side coverage, admission transitions, timeout finalization, and signing lifecycle are exercised together.
| if (snap.state == POOL_STATE_ACCEPTING_ENTRIES && CCoinJoinServer::HasTimedOut() && snap.sides.IsCovered() && | ||
| snap.entries >= static_cast<size_t>(CoinJoin::GetMinPoolParticipants())) { | ||
| // Punish misbehaving participants | ||
| ChargeFees(); | ||
| // Try to complete this session ignoring the misbehaving ones | ||
| CreateFinalTransaction(); | ||
| CreateFinalTransaction(snap.session_id); |
There was a problem hiding this comment.
🔴 Blocking: Timeout finalization can use stale side coverage
The timeout branch decides that the session is covered from snap.sides, but then releases cs_coinjoin, calls ChargeFees(), and only rechecks the session identity/state in CreateFinalTransaction(). A DSVIN already validating on the message-handler thread can commit during that interval because the session remains in POOL_STATE_ACCEPTING_ENTRIES. For example, a snapshot containing three demotion entries has covered sides 0/3; if the admitted set also contains two promoters, one promoter can commit while fees are charged, changing the live sides to 1/3. CreateFinalTransaction() then includes that newly committed entry and publishes an uncovered transaction with a lone promoter. Recheck GetMixSideCountsLocked().IsCovered() and the minimum entry count under the same cs_coinjoin lock used to build the final transaction.
source: ['codex']
| for (const auto& outpoint : vecCoins) { | ||
| const auto it = m_wallet->mapWallet.find(outpoint.hash); | ||
| if (it == m_wallet->mapWallet.end()) continue; | ||
| const wallet::CWalletTx& wtx = it->second; | ||
| if (outpoint.n >= wtx.tx->vout.size()) { | ||
| WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::%s -- invalid outpoint index %u for tx %s\n", __func__, outpoint.n, outpoint.hash.ToString()); | ||
| continue; | ||
| } | ||
| vecTxDSInRet.emplace_back(CTxIn(outpoint), wtx.tx->vout[outpoint.n].scriptPubKey, |
There was a problem hiding this comment.
when outpoint.n could be bigger than wtx.tx->vout.size()?
Is it possible situation for normally working wallet?
There was a problem hiding this comment.
maybe throw an exception better? It won't corrupt wallet but it should never happen if implementation is valid
Summary
This PR adds CoinJoin denomination promotion and demotion so participants can convert between adjacent standard denominations inside a mixing session instead of being limited to strict 1:1 denomination mixes.
The new behavior is gated by V24 activation. Pre-V24 behavior remains unchanged.
What was done?
AddEntry→IsValidInOutsso their collateral is consumed as before (anti-spam behavior preserved)GetRealOutpointCoinJoinRounds(0 rounds for an output whose own inputs in the same tx are at a different denomination); the rule is inert pre-V24 and needs no activation gatingCoinJoin::ValidateFinalTxComposition()and covered it directly in unit testsProtocol-version gating of rebalance sessions
PROTOCOL_VERSIONto 70241 and addedCOINJOIN_REBALANCE_VERSION; thedsamessage gained a flags field declaring promotion/demotion intent, serialized only between peers that both negotiated ≥ 70241, so the wire format toward older peers is byte-identical and DSQ messages are untoucheddsatime withERR_VERSION(a message ID old releases already understand, delivered before any collateral is committed) — this prevents pre-70241 wallets from ever facing an unbalanced final transaction they cannot validate, which they would refuse to sign at the risk of losing their collateral toChargeFees. Old clients keep mixing in sessions created by old peers, which new clients still join for standard mixingdsa, andIsSessionReadyholds the session in queue until the declared shapes cover both sides of the session denomination; a session that never attracts the missing counterparty times out fee-free in queue state. Because admission relies on the declarations, every entry must match its participant's declared direction exactly — a deviating entry (e.g. declaring a promotion, then submitting a standard entry, which could strip a side of its declared cover and force a fee-free reset for everyone) has its collateral consumedHistory
The branch is rebased onto current
develop(#7507, which it previously depended on, has since merged, so its commit is no longer carried here). Commits: feature, tests, release notes, and protocol-version gating from earlier review rounds, plus follow-ups from review: declared-direction enforcement with collateral consumption, activation-boundary tip-skew tolerance, release-note clarifications, version-gated announcement of unbalanced DSTXes, promotion input selection aligned with the standard selector (spendable-only, shuffled, at most one coin per parent transaction — so a demotion's 10 sibling outputs are never promoted together as an identifiable group),nFlagsinCCoinJoinAcceptequality, and the rounds reset for conversion outputs described above.How Has This Been Tested?
./src/test/test_dash --run_test=coinjoin_inouts_tests(including coverage for version-gateddsaserialization, mix-shape classification, and the side-coverage invariant),--run_test=coinjoin_tests, and--run_test=net_testspasscoinjoin_rebalance_rounds_reset_testsin the wallet suite exercisesGetRealOutpointCoinJoinRoundsdirectly: standard 1:1 mixing advances rounds, promotion/demotion-shaped transactions reset their outputs to 0, and a promoted coin advances normally when re-mixeddashdandtest_dashbuild cleanlyP2P_VERSIONin the functional-test framework is bumped in lockstep withPROTOCOL_VERSION, so functional tests can exercise the new gating; an old client is now cleanly emulatable with a 70240-advertisingP2PInterfacePost-V24 activation behavior (including the server-side admission/entry-enforcement paths) still needs functional coverage because EHF activation paths cannot be fully exercised in these unit tests alone.
Breaking Changes
None. The feature is activation-gated and preserves existing pre-V24 behavior. The protocol version bump to 70241 is backward compatible: older peers keep the previous
dsawire format and are only excluded from sessions that could contain entries they cannot validate.